diff --git a/.env.example b/.env.example index f7a57919..598bd331 100644 --- a/.env.example +++ b/.env.example @@ -198,10 +198,16 @@ TMUX_SESSION_PREFIX=webssh TAILSCALE_SSH_ENABLED=false # Non-admin WebSSH usernames allowed to use the shared identity (comma-separated). TAILSCALE_SSH_ALLOWED_WEBSSH_USERS= -# Optional exact target host/IP allowlist. Empty means no additional host filter. +# Required exact target/port allowlist when Tailscale SSH is enabled. A bare +# hostname, IPv4, or IPv6 address means port 22; use hostname:port, IPv4:port, +# or [IPv6]:port for another port. Production rejects malformed entries; +# homelab warns, ignores only those entries, and otherwise fails closed. TAILSCALE_SSH_ALLOWED_TARGETS= # Optional exact remote OS username allowlist. Empty means no additional filter. TAILSCALE_SSH_ALLOWED_REMOTE_USERS= +# Kernel interface that every selected target address must route through. An +# empty value is rejected in production and makes homelab connections fail closed. +TAILSCALE_SSH_INTERFACE=tailscale0 # ─── Rate limiting ─────────────────────────────────────────────────────────── RATELIMIT_ENABLED=True @@ -218,6 +224,16 @@ SSH_KEY_MAX_RECORDS=100 SSH_KEY_STORE_MAX_BYTES=8388608 # Per-user command and command-set mutation rate. # COMMAND_MUTATION_RATELIMIT=60 per minute +# Per-user profile and jump-host mutation rate. +# CONNECTION_MUTATION_RATELIMIT=60 per minute +# Persistent saved-connection limits. Existing oversized stores are quarantined +# from normal UI/runtime access. They are readable and reducible only through +# the bounded offline `connection-store` Flask CLI while every WebSSH process +# using DATA_DIR is stopped; they cannot grow. +# PROFILE_MAX_RECORDS=500 +# JUMP_HOST_MAX_RECORDS=100 +# CONNECTION_STORE_MAX_BYTES=2097152 +# CONNECTION_CONFIG_MAX_BYTES=4194304 # Storage backend for rate-limit counters. # memory:// (default) — per-process, no external dependency. # redis://host:port/db — counters survive app restarts while Redis is running. @@ -253,8 +269,30 @@ MAX_PREVIEW_TAIL_LINES=10000 MAX_SUPPORTED_FILE_SIZE=1073741824 # Timeout for one SFTP channel operation in seconds. SFTP_OPERATION_TIMEOUT=30 +# Maximum declared SFTP protocol packet accepted before its body is read. +SFTP_MAX_PACKET_BYTES=1048576 +# Maximum opaque SFTP directory-handle length accepted from a server. +SFTP_MAX_HANDLE_BYTES=16384 +# Maximum UTF-8 bytes per remote filename/longname and per file-control path. +REMOTE_FILENAME_MAX_BYTES=4096 +FILE_CONTROL_MAX_PATH_BYTES=4096 +# Aggregate metadata and pagination limits for one SFTP directory listing. +REMOTE_LISTING_MAX_METADATA_BYTES=4194304 +REMOTE_LISTING_PAGE_SIZE=500 +# Per-user rolling byte budget for file-control metadata. +FILE_CONTROL_BYTES_PER_MINUTE=2097152 # Maximum text file size accepted by the inline editor (5 MiB). MAX_EDITOR_FILE_SIZE=5242880 +# Per-user rolling byte budget for accepted inline-editor save bodies (20 MiB). +EDITOR_SAVE_BYTES_PER_MINUTE=20971520 +# Short-lived, bounded directory snapshots keep remote pagination stable and +# prevent every page from repeating the complete SFTP/SMB listing. +REMOTE_LISTING_SNAPSHOT_TTL_SECONDS=60 +REMOTE_LISTING_SNAPSHOT_MAX_STATES=8 +REMOTE_LISTING_SNAPSHOT_MAX_PER_USER=4 +# Hard pre-parse ceiling used only by that bounded offline recovery CLI. +CONNECTION_STORE_RECOVERY_MAX_BYTES=16777216 +CONNECTION_STORE_RECOVERY_MAX_RECORDS=10000 # Local disk used only for bounded fallback ZIP creation (default: DATA_DIR/tmp) # TRANSFER_TEMP_DIR=/var/lib/webssh/tmp @@ -269,12 +307,14 @@ BACKUP_MAX_FILE_SIZE=1073741824 BACKUP_MAX_TOTAL_SIZE=10737418240 BACKUP_MAX_COMPRESSION_RATIO=200 BACKUP_MAX_MANIFEST_SIZE=10485760 -# Native Admin backup and restore are always available to administrators. -# Operational limits only; BACKUP_TEMP_DIR is a shared base outside DATA_DIR. +# Native Admin backup is available to administrators. Online restore additionally +# requires a private, durable BACKUP_TEMP_DIR outside DATA_DIR and an explicit +# durability acknowledgement. Ephemeral /tmp is intentionally insufficient. BACKUP_UPLOAD_MAX_SIZE=1073741824 BACKUP_OPERATION_TIMEOUT=1800 BACKUP_DOWNLOAD_TTL=600 # BACKUP_TEMP_DIR=/tmp/webssh-backup-operations +# BACKUP_RECOVERY_DURABLE=false RATELIMIT_BACKUP_CREATE=3 per hour RATELIMIT_BACKUP_UPLOAD=5 per hour RATELIMIT_BACKUP_DOWNLOAD=10 per hour diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 526823b0..f40f9313 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -70,8 +70,30 @@ jobs: include: - python_version: '3.14' check_name: pytest + pytest_targets: tests/ + pytest_ignores: --ignore=tests/integration - python_version: '3.11' check_name: pytest (Python 3.11 minimum) + # The production runtime runs the complete suite above. The + # documented minimum version keeps a representative compatibility + # gate across startup, authentication, storage migrations, remote + # protocols, pagination, and threaded transport admission. + pytest_targets: >- + tests/test_admin_cli.py + tests/test_auth.py + tests/test_entrypoint.py + tests/test_factor_bootstrap.py + tests/test_file_service.py + tests/test_gunicorn_command.py + tests/test_ldap_auth.py + tests/test_network_policy.py + tests/test_production_config.py + tests/test_sftp_handler.py + tests/test_smb_protocol_contract.py + tests/test_ssh_manager.py + tests/test_storage_migrations.py + tests/test_threaded_runtime.py + pytest_ignores: '' steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -97,8 +119,9 @@ jobs: - name: Run tests run: >- - pytest tests/ - --ignore=tests/integration + python -m compileall -q app tests && + pytest ${{ matrix.pytest_targets }} + ${{ matrix.pytest_ignores }} -q -n 2 --dist=loadscope @@ -311,8 +334,10 @@ jobs: run: | image_tag="webssh-ci:${GITHUB_SHA}" container_name="webssh-ci-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + recovery_volume="webssh-ci-recovery-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" ownership_label="webssh.ci-run=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" ownership_value="${ownership_label#*=}" + recovery_volume_created=0 cleanup_start_failure() { status=$? @@ -321,7 +346,16 @@ jobs: if [ "$status" -ne 0 ] && docker container inspect "$container_name" >/dev/null 2>&1; then if actual_label="$(docker container inspect --format '{{ index .Config.Labels "webssh.ci-run" }}' "$container_name" 2>/dev/null)"; then if [ "$actual_label" = "$ownership_value" ]; then - docker rm --force "$container_name" || true + docker rm --force --volumes "$container_name" || true + fi + fi + fi + + if [ "$status" -ne 0 ] && [ "$recovery_volume_created" = "1" ] \ + && docker volume inspect "$recovery_volume" >/dev/null 2>&1; then + if actual_label="$(docker volume inspect --format '{{ index .Labels "webssh.ci-run" }}' "$recovery_volume" 2>/dev/null)"; then + if [ "$actual_label" = "$ownership_value" ]; then + docker volume rm "$recovery_volume" || true fi fi fi @@ -334,6 +368,10 @@ jobs: echo "Refusing to reuse pre-existing container $container_name" >&2 exit 1 fi + if docker volume inspect "$recovery_volume" >/dev/null 2>&1; then + echo "Refusing to reuse pre-existing volume $recovery_volume" >&2 + exit 1 + fi docker build --build-arg VCS_REF="${GITHUB_SHA}" --tag "$image_tag" . image_revision="$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$image_tag")" @@ -341,9 +379,16 @@ jobs: echo "Built image revision does not match the checkout" >&2 exit 1 fi + docker volume create \ + --label "$ownership_label" \ + "$recovery_volume" >/dev/null + recovery_volume_created=1 docker run --detach --name "$container_name" \ --label "$ownership_label" \ --env SECRET_KEY="container-smoke-test-secret" \ + --env BACKUP_TEMP_DIR=/app/recovery \ + --env BACKUP_RECOVERY_DURABLE=true \ + --mount type=volume,source="$recovery_volume",target=/app/recovery \ --publish 127.0.0.1::5000 \ "$image_tag" @@ -351,26 +396,47 @@ jobs: echo "WEBSSH_CI_CONTAINER=$container_name" echo "WEBSSH_CI_CONTAINER_LABEL_VALUE=$ownership_value" echo "WEBSSH_CI_CONTAINER_CREATED=1" + echo "WEBSSH_CI_RECOVERY_VOLUME=$recovery_volume" + echo "WEBSSH_CI_RECOVERY_VOLUME_CREATED=1" } >> "$GITHUB_ENV" trap - EXIT - - name: Verify gthread worker and readiness + - name: Verify recovery volume, gthread worker and readiness shell: bash run: | binding="$(docker port "$WEBSSH_CI_CONTAINER" 5000/tcp | head --lines=1)" port="${binding##*:}" + ready=0 for attempt in {1..30}; do ready_status="$(curl --output /dev/null --silent --show-error --write-out '%{http_code}' "http://127.0.0.1:${port}/ready" || true)" if docker logs "$WEBSSH_CI_CONTAINER" 2>&1 | grep --quiet --fixed-strings "Using worker: gthread" \ && [ "$ready_status" = "200" ]; then - exit 0 + ready=1 + break fi sleep 1 done - docker logs "$WEBSSH_CI_CONTAINER" >&2 - exit 1 + if [ "$ready" != "1" ]; then + docker logs "$WEBSSH_CI_CONTAINER" >&2 + exit 1 + fi + + mounted_volume="$(docker inspect --format '{{ range .Mounts }}{{ if eq .Destination "/app/recovery" }}{{ .Name }}{{ end }}{{ end }}' "$WEBSSH_CI_CONTAINER")" + if [ "$mounted_volume" != "$WEBSSH_CI_RECOVERY_VOLUME" ]; then + echo "Recovery path is not backed by the fresh CI volume" >&2 + exit 1 + fi + docker exec "$WEBSSH_CI_CONTAINER" sh -eu -c ' + expected_owner="$(id -u):$(id -g):700" + actual_owner="$(stat --format=%u:%g:%a /app/recovery)" + test "$actual_owner" = "$expected_owner" + test -w /app/recovery + probe=/app/recovery/.webssh-ci-write-probe + : > "$probe" + rm -f "$probe" + ' - name: Verify graceful gthread shutdown shell: bash @@ -385,17 +451,27 @@ jobs: test "$exit_code" = "0" - name: Clean up created container - if: always() && env.WEBSSH_CI_CONTAINER_CREATED == '1' + if: always() shell: bash run: | - if ! docker container inspect "$WEBSSH_CI_CONTAINER" >/dev/null 2>&1; then - exit 0 - fi + if [ "${WEBSSH_CI_CONTAINER_CREATED:-0}" = "1" ] \ + && docker container inspect "$WEBSSH_CI_CONTAINER" >/dev/null 2>&1; then + actual_label="$(docker container inspect --format '{{ index .Config.Labels "webssh.ci-run" }}' "$WEBSSH_CI_CONTAINER")" + if [ "$actual_label" != "$WEBSSH_CI_CONTAINER_LABEL_VALUE" ]; then + echo "Refusing to remove container with unexpected ownership label" >&2 + exit 1 + fi - actual_label="$(docker container inspect --format '{{ index .Config.Labels "webssh.ci-run" }}' "$WEBSSH_CI_CONTAINER")" - if [ "$actual_label" != "$WEBSSH_CI_CONTAINER_LABEL_VALUE" ]; then - echo "Refusing to remove container with unexpected ownership label" >&2 - exit 1 + docker rm --force --volumes "$WEBSSH_CI_CONTAINER" fi - docker rm --force "$WEBSSH_CI_CONTAINER" + if [ "${WEBSSH_CI_RECOVERY_VOLUME_CREATED:-0}" = "1" ] \ + && docker volume inspect "$WEBSSH_CI_RECOVERY_VOLUME" >/dev/null 2>&1; then + actual_label="$(docker volume inspect --format '{{ index .Labels "webssh.ci-run" }}' "$WEBSSH_CI_RECOVERY_VOLUME")" + if [ "$actual_label" != "$WEBSSH_CI_CONTAINER_LABEL_VALUE" ]; then + echo "Refusing to remove volume with unexpected ownership label" >&2 + exit 1 + fi + + docker volume rm "$WEBSSH_CI_RECOVERY_VOLUME" + fi diff --git a/Dockerfile b/Dockerfile index b7d4322e..de244550 100644 --- a/Dockerfile +++ b/Dockerfile @@ -53,12 +53,14 @@ RUN apt-get update \ COPY . /app RUN chown -R appuser:appuser /app && \ - mkdir -p /app/data/logs /app/data/keys /run/webssh-auth && \ + mkdir -p /app/data/logs /app/data/keys /app/recovery /run/webssh-auth && \ chown -R appuser:appuser /app/data && \ + chown appuser:appuser /app/recovery && \ chown appuser:appuser /run/webssh-auth && \ chmod 700 /app/data && \ chmod 700 /app/data/logs && \ chmod 700 /app/data/keys && \ + chmod 700 /app/recovery && \ chmod 700 /run/webssh-auth COPY entrypoint.sh /app/entrypoint.sh diff --git a/app/__init__.py b/app/__init__.py index 6976f6c9..5e169457 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,10 +1,12 @@ -from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, session, abort +from flask import (Flask, abort, current_app, flash, jsonify, redirect, + render_template, request, session, url_for) from flask_socketio import SocketIO from flask_login import login_required, current_user from flask_wtf.csrf import CSRFProtect from werkzeug.middleware.proxy_fix import ProxyFix import config import os +import sys import time from .models import db from .auth import (init_auth, authenticate_user, register_user, @@ -31,6 +33,72 @@ ) csrf = CSRFProtect() +_MAINTENANCE_COMMANDS = frozenset({ + 'backup', + 'connection-store', + 'create-admin', + 'issue-factor-bootstrap', + 'rotate-secret-key', +}) +_FLASK_OPTIONS_WITH_VALUES = frozenset({ + '--app', + '-A', + '--env-file', + '-e', +}) + + +def _is_flask_cli_process(program_name=None, main_module_name=None): + if program_name is None: + program_name = sys.argv[0] + if main_module_name is None: + main_module = sys.modules.get('__main__') + main_module_spec = getattr(main_module, '__spec__', None) + main_module_name = getattr(main_module_spec, 'name', None) + + executable_name = os.path.splitext(os.path.basename(program_name))[0].lower() + return executable_name == 'flask' or main_module_name in { + 'flask.__main__', + 'flask.cli', + } + + +def _flask_top_level_command(arguments): + skip_next = False + for index, argument in enumerate(arguments): + if skip_next: + skip_next = False + continue + if argument == '--': + return arguments[index + 1] if index + 1 < len(arguments) else None + if argument in _FLASK_OPTIONS_WITH_VALUES: + skip_next = True + continue + if any( + argument.startswith(f'{option}=') + for option in _FLASK_OPTIONS_WITH_VALUES + if option.startswith('--') + ): + continue + if argument.startswith('-A') and argument != '-A': + continue + if argument.startswith('-'): + continue + return argument + return None + + +def _is_maintenance_cli_invocation( + arguments=None, + program_name=None, + main_module_name=None, +): + arguments = sys.argv[1:] if arguments is None else arguments + if not _is_flask_cli_process(program_name, main_module_name): + return False + return _flask_top_level_command(arguments) in _MAINTENANCE_COMMANDS + + def get_client_ip(): """ Get the real client IP address. @@ -43,6 +111,257 @@ def get_client_ip(): """ return request.remote_addr or 'unknown' + +def _engineio_admission_user(app, environ): + """Resolve an authenticated browser before Engine.IO retains its transport.""" + from .maintenance_mode import is_active + + if is_active(): + return None + lifecycle = app.extensions.get('runtime_lifecycle') + if lifecycle is None or not lifecycle.accepting_work(): + return None + + from .auth_assurance import ( + current_authentication_session, + recovery_session_required, + ) + from werkzeug.exceptions import HTTPException + + with app.request_context(environ): + try: + preprocessing_response = app.preprocess_request() + except HTTPException: + # Security gates such as recovery-only sessions use abort() for an + # expected denial. Treat them like any other rejected admission. + return None + if preprocessing_response is not None: + return None + if not current_user.is_authenticated: + return None + auth_session = current_authentication_session() + if auth_session is None or recovery_session_required(auth_session): + return None + return int(current_user.id) + + +def _engineio_admission_is_current(app, environ, expected_user_id): + """Recheck mutable auth state after the transport reservation is visible.""" + from flask import g + + from .auth_assurance import ( + current_authentication_session, + recovery_session_required, + ) + from .ldap_session import ldap_revocation_pending + from .maintenance_mode import is_active + from .models import db + + if is_active(): + return False + lifecycle = app.extensions.get('runtime_lifecycle') + if lifecycle is None or not lifecycle.accepting_work(): + return False + + with app.request_context(environ): + # The first admission pass may share an outer app context in tests or + # embedded WSGI hosts. Expire ORM state and Flask-Login's request cache + # so a concurrently committed lock/generation change is observable. + db.session.expire_all() + g.pop('_login_user', None) + if ( + not current_user.is_authenticated + or int(current_user.id) != int(expected_user_id) + or ( + current_user.is_ldap_managed + and ( + not config.LDAP_ENABLED + or ldap_revocation_pending(app, current_user.id) + ) + ) + ): + return False + stored_epoch = session.get('_auth_epoch') + if stored_epoch is not None: + from .session_epoch import current_epoch + + if stored_epoch != current_epoch(): + return False + auth_session = current_authentication_session() + return ( + auth_session is not None + and auth_session.user_id == int(expected_user_id) + and not recovery_session_required(auth_session) + ) + + +def _install_engineio_admission(app): + """Bound transports before they can retain Engine.IO state or threads.""" + from threading import Event + + from .socket_capacity import SocketCapacityRegistry + + server = socketio.server + engineio_server = server.eio + original_socketio_handle_connect = server._handle_connect + original_handle_connect = engineio_server._handle_connect + original_connect = engineio_server.handlers.get('connect') + original_disconnect = engineio_server.handlers.get('disconnect') + if not callable(original_connect) or not callable(original_disconnect): + raise RuntimeError('Socket.IO Engine.IO lifecycle handlers are unavailable') + + capacity = SocketCapacityRegistry() + app.extensions['engineio_socket_capacity'] = capacity + # Bind admission state to this exact Engine.IO server. Delayed cleanup + # workers must never consult a newer app/server created by a test or reload. + engineio_server._webssh_socket_capacity = capacity + + def handle_socketio_namespace_connect(engineio_sid, namespace, data): + """Keep revocation outside Socket.IO's complete connect frame.""" + owner_id = capacity.owner(engineio_sid) + if owner_id is None: + if app.testing and engineio_sid not in engineio_server.sockets: + return original_socketio_handle_connect( + engineio_sid, + namespace, + data, + ) + return None + engineio_socket = engineio_server.sockets.get(engineio_sid) + if engineio_socket is None: + return None + with capacity.admission_guard(engineio_sid, owner_id) as admitted: + if ( + not admitted + or engineio_server.sockets.get(engineio_sid) + is not engineio_socket + ): + return None + return original_socketio_handle_connect( + engineio_sid, + namespace, + data, + ) + + def handle_engineio_http_connect( + environ, + start_response, + transport, + jsonp_index=None, + ): + """Signal when Engine.IO no longer owns initial socket bookkeeping.""" + failed = False + try: + return original_handle_connect( + environ, + start_response, + transport, + jsonp_index=jsonp_index, + ) + except BaseException: + failed = True + raise + finally: + initialization = environ.pop( + 'webssh.engineio_initialization', + None, + ) + if initialization is not None: + engineio_sid, engineio_socket, initialization_done = ( + initialization + ) + if failed: + current_socket = engineio_server.sockets.get( + engineio_sid + ) + if current_socket is engineio_socket: + try: + engineio_socket.close( + wait=False, + abort=True, + reason=( + engineio_server.reason.SERVER_DISCONNECT + ), + ) + except BaseException: + pass + finally: + if ( + engineio_server.sockets.get(engineio_sid) + is engineio_socket + ): + engineio_server.sockets.pop( + engineio_sid, + None, + ) + capacity.release(engineio_sid) + elif current_socket is None: + capacity.release(engineio_sid) + initialization_done.set() + + def handle_engineio_connect(engineio_sid, environ): + engineio_socket = engineio_server.sockets.get(engineio_sid) + if engineio_socket is None: + return False + initialization_done = Event() + engineio_socket._webssh_initialization_done = initialization_done + environ['webssh.engineio_initialization'] = ( + engineio_sid, + engineio_socket, + initialization_done, + ) + try: + user_id = _engineio_admission_user(app, environ) + if user_id is None or not capacity.reserve( + user_id, + engineio_sid, + config.MAX_SOCKET_CONNECTIONS, + config.MAX_SOCKET_CONNECTIONS_PER_USER, + ): + return False + if ( + not _engineio_admission_is_current(app, environ, user_id) + or capacity.owner(engineio_sid) != user_id + or capacity.is_terminal(engineio_sid) + ): + capacity.release(engineio_sid) + return False + result = original_connect(engineio_sid, environ) + except Exception as error: + capacity.release(engineio_sid) + # Engine.IO has already inserted this SID before invoking its + # connect handler. Returning False lets Engine.IO remove it; an + # escaping exception would retain the half-open transport. + try: + log_error( + 'Engine.IO transport admission failed closed', + error_type=type(error).__name__, + sid=engineio_sid, + ) + except Exception: + # Logging must never turn a controlled rejection into a + # retained Engine.IO transport. + pass + return False + if result is not None and result is not True: + capacity.release(engineio_sid) + return result + + def handle_engineio_disconnect(engineio_sid, reason): + try: + return original_disconnect(engineio_sid, reason) + finally: + capacity.release(engineio_sid) + + # python-socketio installs its bookkeeping callbacks first. Chain them + # through Engine.IO's public event API so rejected transports never reach + # ping scheduling, polling retention, or WebSocket handling. + server._handle_connect = handle_socketio_namespace_connect + engineio_server._handle_connect = handle_engineio_http_connect + engineio_server.on('connect', handle_engineio_connect) + engineio_server.on('disconnect', handle_engineio_disconnect) + + def _initialize_persistent_storage(app): """Initialize storage and schema for serving or explicit CLI mutation.""" if app.extensions.get('persistent_storage_initialized'): @@ -74,6 +393,11 @@ def create_app( start_runtime=True, initialize_oidc=True, ): + maintenance_cli_invocation = _is_maintenance_cli_invocation() + if maintenance_cli_invocation: + initialize_storage = False + start_runtime = False + initialize_oidc = False base_dir = os.path.dirname(os.path.dirname(__file__)) template_dir = os.path.join(base_dir, 'templates') static_dir = os.path.join(base_dir, 'static') @@ -84,10 +408,16 @@ def create_app( app.extensions['runtime_lifecycle'] = RuntimeLifecycle( max_workers=config.BACKGROUND_WORKERS ) + from .ldap_session import LDAPRevocationFence + app.extensions['ldap_revocation_fence'] = LDAPRevocationFence( + config.DATA_DIR / '.ldap-revocation-fences' + ) + app.extensions['maintenance_cli_invocation'] = maintenance_cli_invocation from .maintenance_mode import is_active, recover_interrupted_restore if initialize_storage: recover_interrupted_restore() + maintenance_active = is_active() for warning in config.SECURITY_CONFIG_WARNINGS: log_warning('Deployment security warning', warning=warning) @@ -165,6 +495,26 @@ def hide_disabled_admin_panel(): @app.before_request def enforce_restore_maintenance_and_session_epoch(): + def invalidate_ldap_browser_access(reason): + from . import user_lifecycle + from .ldap_session import persist_ldap_authentication_invalidation + + user_id = current_user.id + username = current_user.username + invalidation_error = persist_ldap_authentication_invalidation( + current_app._get_current_object(), + current_user, + ) + if invalidation_error is not None: + log_error( + 'LDAP authentication invalidation failed', + user=username, + reason=reason, + error_type=type(invalidation_error).__name__, + ) + user_lifecycle.revoke_user_access(user_id, socketio) + clear_browser_authentication() + if is_active() and request.path not in { '/health', '/ready', @@ -181,13 +531,18 @@ def enforce_restore_maintenance_and_session_epoch(): if ( current_user.is_authenticated and current_user.is_ldap_managed - and not config.LDAP_ENABLED ): - from . import user_lifecycle + from .ldap_session import ldap_revocation_pending - user_id = current_user.id - user_lifecycle.revoke_user_access(user_id, socketio) - clear_browser_authentication() + if ldap_revocation_pending(current_app, current_user.id): + invalidate_ldap_browser_access('pending_invalidation') + return redirect(url_for('login')) + if ( + current_user.is_authenticated + and current_user.is_ldap_managed + and not config.LDAP_ENABLED + ): + invalidate_ldap_browser_access('disabled') return redirect(url_for('login')) if ( current_user.is_authenticated @@ -196,23 +551,37 @@ def enforce_restore_maintenance_and_session_epoch(): and int(time.time()) - int(session.get('_ldap_verified_at', 0)) >= config.LDAP_SESSION_REVALIDATION_SECONDS ): - from . import user_lifecycle from .ldap_service import LDAPLookupRejected, LDAPUnavailable - from .ldap_session import revalidate_user + from .ldap_session import ( + LDAPValidationInProgress, + ensure_recent_ldap_validation, + ) try: - revalidate_user(current_user) + receipt = ensure_recent_ldap_validation( + current_app._get_current_object(), + current_user, + max_age_seconds=( + config.LDAP_SESSION_REVALIDATION_SECONDS + ), + ) + except LDAPValidationInProgress: + response = jsonify({ + 'error': 'Directory session validation is in progress', + 'code': 'ldap_validation_in_progress', + }) + response.status_code = 503 + response.headers['Retry-After'] = '1' + return response except (LDAPLookupRejected, LDAPUnavailable) as exc: - user_id = current_user.id log_warning( 'LDAP session revalidation rejected', user=current_user.username, error=type(exc).__name__, ) - user_lifecycle.revoke_user_access(user_id, socketio) - clear_browser_authentication() + invalidate_ldap_browser_access(type(exc).__name__) return redirect(url_for('login')) - session['_ldap_verified_at'] = int(time.time()) + session['_ldap_verified_at'] = receipt.verified_at_epoch if initialize_storage and current_user.is_authenticated: from .session_epoch import current_epoch epoch = current_epoch() @@ -323,19 +692,20 @@ def enforce_security_feature_gate(): ldap_ready = not config.LDAP_ENABLED if config.LDAP_ENABLED: from .ldap_routes import ldap_blueprint - from .ldap_service import validate_runtime_files - validate_runtime_files() + if not maintenance_cli_invocation: + from .ldap_service import validate_runtime_files + validate_runtime_files() + ldap_ready = True app.register_blueprint(ldap_blueprint) - ldap_ready = True from .security_features import initialize_feature_readiness initialize_feature_readiness( app, oidc_ready=oidc_ready, ldap_ready=ldap_ready, ) - if initialize_storage: + if initialize_storage and not maintenance_active: _initialize_persistent_storage(app) - if start_runtime: + if start_runtime and not maintenance_active: from .backup_operations import backup_operations backup_operations.cleanup_orphans() app.extensions['runtime_lifecycle'].start_job( @@ -370,6 +740,7 @@ def enforce_security_feature_gate(): logger=False, engineio_logger=False ) + _install_engineio_admission(app) @app.after_request def add_security_headers(response): @@ -474,7 +845,7 @@ def ldap_revalidation_task(cancel_event): raise log_info("Background session cleanup tasks started") - if start_runtime: + if start_runtime and not maintenance_active: setup_background_tasks() @app.route('/') diff --git a/app/account_step_up_routes.py b/app/account_step_up_routes.py index 31074429..77bfa77a 100644 --- a/app/account_step_up_routes.py +++ b/app/account_step_up_routes.py @@ -29,11 +29,13 @@ available_mfa_methods, current_authentication_session, ) +from .factor_bootstrap import ( + consume_factor_bootstrap, + has_live_factor_bootstrap, +) from .ldap_service import LDAPDirectory, LDAPLookupRejected, LDAPUnavailable -from .github_auth_service import github_auth_is_active from .models import ( LDAPIdentity, - GitHubIdentity, OIDCIdentity, TOTPAuthenticator, User, @@ -142,12 +144,9 @@ def _allowed_methods(user, auth_session, required_assurance): and OIDCIdentity.query.filter_by(user_id=user.id).first() is not None ): return ["oidc"] - if ( - "github" in methods - and github_auth_is_active() - and GitHubIdentity.query.filter_by(user_id=user.id).first() is not None - ): - return ["github"] + # GitHub's OAuth authorization response has no signed authentication-time + # evidence. An ambient provider session cannot prove fresh independent + # authentication for factor or recovery changes. if ( "ldap" in methods and feature_is_active("ldap") @@ -156,14 +155,19 @@ def _allowed_methods(user, auth_session, required_assurance): return ["ldap"] if "password" in methods and not user.is_ldap_managed: return ["password"] - if ( - "passkey" in methods - and "passkey" in available_mfa_methods(user) - ): + if "passkey" in available_mfa_methods(user): return ["passkey"] return [] +def _bootstrap_available(user, auth_session, action): + """Require both the GitHub login and a local operator-issued code.""" + return bool( + "github" in authentication_methods(auth_session) + and has_live_factor_bootstrap(user, action) + ) + + def _intent_context(token): auth_session = current_authentication_session() if auth_session is None: @@ -237,7 +241,10 @@ def create_intent(): methods = _allowed_methods(user, auth_session, required) recent = recent_strong_assurance(auth_session) if not methods and recent is None: - return _error("step_up_failed", 403) + if _bootstrap_available(user, auth_session, action): + methods = ["bootstrap"] + else: + return _error("step_up_failed", 403) token, intent = create_account_step_up_intent( auth_session, action, target ) @@ -262,6 +269,42 @@ def create_intent(): }) +@account_step_up_blueprint.post("/api/account/step-up/bootstrap") +@login_required +def bootstrap_step_up(): + try: + data = _request_data() + token = data.get("intent") + intent, user, auth_session = _intent_context(token) + if not _bootstrap_available(user, auth_session, intent.action): + return _error("step_up_failed", 403) + except StepUpError: + return _error("step_up_failed", 403) + if _reauth_limited("bootstrap"): + return _rate_limit_error() + if not consume_factor_bootstrap( + user, + intent.action, + data.get("code"), + ): + log_security_event( + "ACCOUNT_STEP_UP_REJECTED", + user=current_user.username, + action=intent.action, + method="bootstrap", + ) + return _error("step_up_failed", 403) + try: + return _complete_intent( + token, + auth_session, + AssuranceLevel.BASIC.value, + "bootstrap", + ) + except StepUpError: + return _error("step_up_failed", 403) + + @account_step_up_blueprint.post("/api/account/step-up/password") @login_required def password_step_up(): diff --git a/app/admin_backup.py b/app/admin_backup.py index 4169e3cb..a68b6715 100644 --- a/app/admin_backup.py +++ b/app/admin_backup.py @@ -447,6 +447,14 @@ def restore_uploaded_backup(operation_id): or data.get('confirmation_phrase') != 'RESTORE' ): return jsonify({'error': 'Explicit restore confirmation is required'}), 400 + from .backup_coordination import require_durable_recovery_storage + try: + require_durable_recovery_storage() + except RuntimeError: + return jsonify({ + 'error': 'Web restore requires durable recovery storage', + 'code': 'RECOVERY_STORAGE_REQUIRED', + }), 503 try: record = backup_operations.get( operation_id, current_user.id, _admin_session_id() @@ -468,10 +476,31 @@ def restore_uploaded_backup(operation_id): username = current_user.username source_ip = request.remote_addr or 'unknown' - log_security_event('RESTORE_STARTED', user=username, ip=source_ip) from .restore_service import start_restore - start_restore(current_app._get_current_object(), socketio, record, - username, source_ip) + try: + start_restore(current_app._get_current_object(), socketio, record, + username, source_ip) + except BaseException as error: + control_flow_error = not isinstance(error, Exception) + if ( + not control_flow_error + and not getattr(error, 'restore_worker_started', False) + ): + backup_operations.reset_unstarted_restore(record.operation_id) + if control_flow_error: + raise + log_security_event( + 'RESTORE_START_FAILED', + level=logging.ERROR, + user=username, + ip=source_ip, + error_type=type(error).__name__, + ) + return jsonify({ + 'error': 'Restore could not be started', + 'code': 'RESTORE_START_FAILED', + }), 503 + log_security_event('RESTORE_STARTED', user=username, ip=source_ip) return jsonify(_operation_payload(record)), 202 diff --git a/app/audit_logger.py b/app/audit_logger.py index b081319a..96478182 100644 --- a/app/audit_logger.py +++ b/app/audit_logger.py @@ -2,6 +2,7 @@ import json import sys from collections.abc import Mapping +from itertools import islice from threading import RLock from logging.handlers import RotatingFileHandler from pathlib import Path @@ -203,7 +204,7 @@ def log_info(message, **kwargs): record = logging.LogRecord( 'webssh', logging.INFO, '', 0, safe_message, (), None ) - record.extra_data = kwargs + record.extra_data = _sanitize_structured_log_data(kwargs) app_logger.handle(record) else: app_logger.info(safe_message) @@ -215,7 +216,7 @@ def log_warning(message, **kwargs): record = logging.LogRecord( 'webssh', logging.WARNING, '', 0, safe_message, (), None ) - record.extra_data = kwargs + record.extra_data = _sanitize_structured_log_data(kwargs) app_logger.handle(record) else: app_logger.warning(safe_message) @@ -227,7 +228,7 @@ def log_error(message, exc_info=False, **kwargs): record = logging.LogRecord( 'webssh', logging.ERROR, '', 0, safe_message, (), None ) - record.extra_data = kwargs + record.extra_data = _sanitize_structured_log_data(kwargs) if exc_info: import sys record.exc_info = sys.exc_info() @@ -242,7 +243,7 @@ def log_debug(message, **kwargs): record = logging.LogRecord( 'webssh', logging.DEBUG, '', 0, safe_message, (), None ) - record.extra_data = kwargs + record.extra_data = _sanitize_structured_log_data(kwargs) app_logger.handle(record) else: app_logger.debug(safe_message) @@ -260,6 +261,33 @@ def _sanitize_log_value(value): return s[:512] +def _bounded_structured_log_value(value, depth=0): + if depth >= 4: + return '[TRUNCATED]' + if value is None or isinstance(value, (bool, int, float)): + return value + if isinstance(value, str): + return _sanitize_log_value(value) + if isinstance(value, Mapping): + return { + _sanitize_log_value(key): _bounded_structured_log_value(item, depth + 1) + for key, item in islice(value.items(), 64) + } + if isinstance(value, (list, tuple, set, frozenset)): + return [ + _bounded_structured_log_value(item, depth + 1) + for item in islice(iter(value), 64) + ] + return _sanitize_log_value(value) + + +def _sanitize_structured_log_data(values): + return { + _sanitize_log_value(key): _bounded_structured_log_value(value) + for key, value in islice(values.items(), 64) + } + + _SENSITIVE_AUDIT_DETAIL_KEYS = frozenset({ 'assertion', 'authorization', @@ -302,23 +330,33 @@ def _audit_detail_key_is_sensitive(key): ) -def _redact_audit_detail(key, value): +def _redact_audit_detail(key, value, depth=0): if _audit_detail_key_is_sensitive(key): return '[REDACTED]' + if depth >= 4: + return '[TRUNCATED]' if isinstance(value, Mapping): - return sanitize_audit_details(value) - if isinstance(value, list): - return [_redact_audit_detail('', item) for item in value] - if isinstance(value, tuple): - return tuple(_redact_audit_detail('', item) for item in value) - return value + return { + _sanitize_log_value(nested_key): _redact_audit_detail( + nested_key, item, depth + 1 + ) + for nested_key, item in islice(value.items(), 64) + } + if isinstance(value, (list, tuple, set, frozenset)): + return [ + _redact_audit_detail('', item, depth + 1) + for item in islice(iter(value), 64) + ] + if value is None or isinstance(value, (bool, int, float)): + return value + return _sanitize_log_value(value) def sanitize_audit_details(details): """Return structured audit details with secret-bearing fields redacted.""" return { - str(key): _redact_audit_detail(key, value) - for key, value in details.items() + _sanitize_log_value(key): _redact_audit_detail(key, value) + for key, value in islice(details.items(), 64) } def log_login_attempt(username, success, ip_address, user_agent=None): diff --git a/app/auth_assurance.py b/app/auth_assurance.py index 0e65ae05..fa838ee8 100644 --- a/app/auth_assurance.py +++ b/app/auth_assurance.py @@ -425,9 +425,15 @@ def finalize_login(pending, *, methods, strong_authenticated_at=None): ) from exc user = db.session.get(User, pending.user_id) + fenced_ldap_identity = False + if user is not None and user.is_ldap_managed: + from .ldap_session import ldap_revocation_pending + + fenced_ldap_identity = ldap_revocation_pending(current_app, user.id) if ( user is None or user.is_locked + or fenced_ldap_identity or ( user.is_admin and (user.is_ldap_managed or user.is_github_managed) diff --git a/app/backup_coordination.py b/app/backup_coordination.py index eb41aa1e..364cb2d7 100644 --- a/app/backup_coordination.py +++ b/app/backup_coordination.py @@ -17,6 +17,21 @@ class OperationBusyError(RuntimeError): """Raised when another backup-sensitive operation owns the process lock.""" +def require_durable_recovery_storage() -> None: + """Fail closed before web restore can mutate persistent application data.""" + if not config.BACKUP_RECOVERY_DURABLE: + raise RuntimeError( + 'Web restore requires durable recovery storage' + ) + configured = Path(config.BACKUP_TEMP_DIR).expanduser() + if not configured.is_absolute(): + raise RuntimeError('BACKUP_TEMP_DIR must be absolute for web restore') + # This also rejects overlap with DATA_DIR, symlinks and non-private paths. + # Durability itself is an operator/mount contract represented by the + # explicit BACKUP_RECOVERY_DURABLE acknowledgement. + ensure_backup_temp_dir() + + @dataclass(frozen=True) class OperationToken: value: str diff --git a/app/backup_manager.py b/app/backup_manager.py index a3aa912f..b5e210c9 100644 --- a/app/backup_manager.py +++ b/app/backup_manager.py @@ -17,13 +17,20 @@ _FORMAT_VERSION = 2 _LEGACY_FORMAT_VERSION = 1 -_CURRENT_DATA_SCHEMA_VERSION = 1 -_DATA_SCHEMA_MIGRATIONS = {0: 1} +_CURRENT_DATA_SCHEMA_VERSION = 2 +_DATA_SCHEMA_MIGRATIONS = {0: 1, 1: 2} _PRODUCER = 'webssh' _MANIFEST_NAME = 'manifest.json' _DATA_PREFIX = 'data/' _DATABASE_PATH = 'app.db' -_EXCLUDED_TOP_LEVEL_DIRECTORIES = {'logs', 'tmp'} +_RESTORE_PRESERVED_TOP_LEVEL_DIRECTORIES = {'logs', 'tmp'} +_RESTORE_DISCARDED_TOP_LEVEL_DIRECTORIES = { + '.ldap-revocation-fences', +} +_BACKUP_EXCLUDED_TOP_LEVEL_DIRECTORIES = ( + _RESTORE_PRESERVED_TOP_LEVEL_DIRECTORIES + | _RESTORE_DISCARDED_TOP_LEVEL_DIRECTORIES +) class BackupIntegrityError(ValueError): @@ -300,9 +307,14 @@ def _stage_source(data_dir, stage, excluded_relative_paths=frozenset()): if current == data_dir: directory_names[:] = [ name for name in directory_names - if name not in _EXCLUDED_TOP_LEVEL_DIRECTORIES + if name not in _BACKUP_EXCLUDED_TOP_LEVEL_DIRECTORIES ] for file_name in file_names: + if ( + current == data_dir + and file_name in _BACKUP_EXCLUDED_TOP_LEVEL_DIRECTORIES + ): + continue source = current / file_name relative = source.relative_to(data_dir).as_posix() if relative in excluded_relative_paths: @@ -621,7 +633,7 @@ def _existing_persistent_files(data_dir): if current == data_dir: directory_names[:] = [ name for name in directory_names - if name not in _EXCLUDED_TOP_LEVEL_DIRECTORIES + if name not in _RESTORE_PRESERVED_TOP_LEVEL_DIRECTORIES ] for directory_name in directory_names: if (current / directory_name).is_symlink(): @@ -634,7 +646,7 @@ def _existing_persistent_files(data_dir): if ( len(relative.parts) > 1 and relative.parts[0] - in _EXCLUDED_TOP_LEVEL_DIRECTORIES + in _RESTORE_PRESERVED_TOP_LEVEL_DIRECTORIES ): continue path_stat = path.lstat() @@ -749,10 +761,21 @@ def restore_backup(archive, data_dir): manifest = verify_backup(archive) require_restore_compatible(manifest) _validate_restore_targets(data_dir, manifest) + restorable_manifest = BackupManifest( + manifest.format_version, + tuple( + item for item in manifest.files + if PurePosixPath(item.path).parts[0] + not in _RESTORE_DISCARDED_TOP_LEVEL_DIRECTORIES + ), + manifest.data_schema_version, + manifest.created_at, + manifest.producer, + ) existing_paths = _existing_persistent_files(data_dir) manifest_paths = { Path(*PurePosixPath(item.path).parts) - for item in manifest.files + for item in restorable_manifest.files } extra_paths = existing_paths - manifest_paths data_dir.parent.mkdir(parents=True, exist_ok=True) @@ -773,7 +796,7 @@ def restore_backup(archive, data_dir): 'backup changed after initial verification' ) extracted_total = 0 - for item in manifest.files: + for item in restorable_manifest.files: relative = Path(*PurePosixPath(item.path).parts) target = stage / relative target.parent.mkdir(parents=True, exist_ok=True, mode=0o700) @@ -806,11 +829,11 @@ def restore_backup(archive, data_dir): f'backup checksum or size mismatch for {item.path}' ) os.chmod(target, 0o600) - _verify_staged_files(stage, manifest) + _verify_staged_files(stage, restorable_manifest) _restore_staged_files( stage, data_dir, - manifest, + restorable_manifest, rollback, extra_paths, ) diff --git a/app/backup_operations.py b/app/backup_operations.py index b3621d1f..a0abdb88 100644 --- a/app/backup_operations.py +++ b/app/backup_operations.py @@ -189,6 +189,21 @@ def begin_restore(self, operation_id, owner_id, session_id, record.expires_at = time.time() + config.BACKUP_OPERATION_TIMEOUT return record + def reset_unstarted_restore(self, operation_id): + """Make a verified upload retryable after its worker failed to start.""" + with self._lock: + record = self._records.get(str(operation_id)) + if ( + record is None + or record.kind != 'uploaded_backup' + or record.status != 'restoring' + ): + return None + record.status = 'verified' + record.error = None + record.expires_at = time.time() + config.BACKUP_DOWNLOAD_TTL + return record + def prepare_restore(self, operation_id, owner_id, session_id, ttl=300): with self._lock: record = self.get(operation_id, owner_id, session_id) @@ -212,8 +227,16 @@ def _remove_locked(self, operation_id): def cleanup_orphans(self): root = self._operation_root() - from .maintenance_mode import protected_operation_directory_name + from .maintenance_mode import ( + is_active, + protected_operation_directory_name, + ) protected_name = protected_operation_directory_name() + # An unreadable status file intentionally enters fail-closed + # maintenance without a trustworthy operation ID. Preserve every + # possible recovery directory until an operator repairs the status. + if is_active() and protected_name is None: + return try: lock_context = operation_lock(timeout=0) lock_context.__enter__() diff --git a/app/cli.py b/app/cli.py index 9a6688f2..9d5ab183 100644 --- a/app/cli.py +++ b/app/cli.py @@ -1,3 +1,4 @@ +import json import os import secrets import stat @@ -194,6 +195,163 @@ def warn_if_no_admin(): ) +@click.command('issue-factor-bootstrap') +@click.option('--username', required=True, metavar='NAME') +@click.option( + '--action', + required=True, + type=click.Choice(('passkey.enroll', 'totp.enroll'), case_sensitive=True), +) +def issue_factor_bootstrap(username, action): + """Issue a one-use code for a GitHub-only user's first durable factor.""" + from . import _initialize_persistent_storage + from .factor_bootstrap import ( + FactorBootstrapError, + issue_factor_bootstrap as issue_code, + ) + from .maintenance_mode import is_active + + if is_active(): + raise click.ClickException( + 'Factor bootstrap is unavailable during restore maintenance.' + ) + _initialize_persistent_storage(current_app._get_current_object()) + user = User.query.filter_by(username=username).first() + if user is None: + raise click.ClickException('Eligible account not found.') + try: + token, expires_at = issue_code(user, action) + except FactorBootstrapError as exc: + raise click.ClickException(str(exc)) from exc + _audit_operation( + 'FACTOR_BOOTSTRAP_ISSUED', + user=user.username, + action=action, + expires_at=expires_at.replace(tzinfo=timezone.utc).isoformat(), + ) + expiry = expires_at.replace(tzinfo=timezone.utc).isoformat().replace( + '+00:00', 'Z' + ) + click.echo(f'Enrollment code: {token}') + click.echo( + f'Expires at: {expiry}. This code is single-use and bound to ' + f'{user.username} and {action}.' + ) + + +@click.group('connection-store') +def connection_store_cli(): + """Inspect or reduce quarantined legacy connection stores offline.""" + + +def _connection_store_user(username): + from . import _initialize_persistent_storage + + _initialize_persistent_storage(current_app._get_current_object()) + user = User.query.filter_by(username=username).first() + if user is None: + raise click.ClickException('Account not found.') + return user + + +@connection_store_cli.command('list') +@click.option('--username', required=True, metavar='NAME') +@click.option( + '--kind', + required=True, + type=click.Choice(('profiles', 'jump-hosts'), case_sensitive=True), +) +@click.option('--confirm-offline', is_flag=True) +def connection_store_list(username, kind, confirm_offline): + """List bounded, non-secret record summaries for offline recovery.""" + from . import jump_host_manager, profile_manager + from .backup_coordination import OperationBusyError, operation_lock + from .connection_storage_policy import ConnectionStorageLimitError + from .storage_errors import StorageCorruptionError + + _require_offline_confirmation(confirm_offline) + try: + with operation_lock(): + user = _connection_store_user(username) + if kind == 'profiles': + records, error = ( + profile_manager.load_profile_recovery_summaries(user.id) + ) + if error: + raise click.ClickException(error) + else: + records = ( + jump_host_manager.load_jump_host_recovery_summaries( + user.id + ) + ) + click.echo(json.dumps({ + 'count': len(records), + 'kind': kind, + 'records': records, + }, ensure_ascii=True, sort_keys=True)) + except ( + ConnectionStorageLimitError, + OperationBusyError, + StorageCorruptionError, + ) as exc: + raise click.ClickException(str(exc)) from exc + + +@connection_store_cli.command('delete') +@click.option('--username', required=True, metavar='NAME') +@click.option( + '--kind', + required=True, + type=click.Choice(('profiles', 'jump-hosts'), case_sensitive=True), +) +@click.option('--selector', required=True, metavar='SELECTOR') +@click.option('--confirm-offline', is_flag=True) +def connection_store_delete( + username, + kind, + selector, + confirm_offline, +): + """Delete one exact legacy record while WebSSH is stopped.""" + from . import jump_host_manager, profile_manager + from .backup_coordination import OperationBusyError, operation_lock + from .storage_errors import StorageCorruptionError + + _require_offline_confirmation(confirm_offline) + try: + with operation_lock(): + user = _connection_store_user(username) + if kind == 'profiles': + deleted, error = ( + profile_manager.delete_profile_recovery_record( + user.id, + selector, + ) + ) + else: + deleted, error, _usages = ( + jump_host_manager.delete_jump_host_recovery_record( + user.id, + selector, + ) + ) + if not deleted: + raise click.ClickException( + error or 'Record could not be deleted.' + ) + _audit_operation( + 'CONNECTION_STORE_RECOVERY_DELETE', + user=user.username, + kind=kind, + selector=selector, + ) + label = 'profile' if kind == 'profiles' else 'jump-host' + click.echo(f'Deleted one {label} recovery record.') + except (OperationBusyError, StorageCorruptionError) as exc: + raise click.ClickException(str(exc)) from exc + + @click.group('backup') def backup_cli(): """Create, verify, or restore WebSSH data backups.""" @@ -332,5 +490,7 @@ def rotate_secret_key(confirm_offline): def register_cli(app): app.cli.add_command(create_admin) + app.cli.add_command(issue_factor_bootstrap) + app.cli.add_command(connection_store_cli) app.cli.add_command(backup_cli) app.cli.add_command(rotate_secret_key) diff --git a/app/command_manager.py b/app/command_manager.py index 62315b9e..d9c6fa35 100644 --- a/app/command_manager.py +++ b/app/command_manager.py @@ -277,23 +277,34 @@ def update_user_command(user_id, command_id, name, command, parameters, descript def delete_user_command(user_id, command_id): """Delete a user command.""" - from .command_set_manager import _get_command_usage_with_coordinator_held + from .command_set_manager import ( + _get_command_usage_summary_with_coordinator_held, + ) with storage_lock(f'command-config:{user_id}'): - usages, error = _get_command_usage_with_coordinator_held( - user_id, command_id + usage_count, usage_types, usages, error = ( + _get_command_usage_summary_with_coordinator_held( + user_id, + command_id, + ) ) if error: return False, error, [] - if usages: - usage_types = {usage.get('type') for usage in usages} + if usage_count: if usage_types == {'command_set'}: - noun = 'command set' if len(usages) == 1 else 'command sets' + noun = 'command set' if usage_count == 1 else 'command sets' elif usage_types == {'profile'}: - noun = 'profile' if len(usages) == 1 else 'profiles' + noun = 'profile' if usage_count == 1 else 'profiles' else: - noun = 'reference' if len(usages) == 1 else 'references' - return False, f'Command is used by {len(usages)} {noun}', usages + noun = 'reference' if usage_count == 1 else 'references' + details = '' + if usage_count > len(usages): + details = f' (showing first {len(usages)})' + return ( + False, + f'Command is used by {usage_count} {noun}{details}', + usages, + ) with storage_lock(f'commands:{user_id}'): user_cmds, error = _load_user_commands_for_write(user_id) diff --git a/app/command_set_manager.py b/app/command_set_manager.py index dde4d01f..da6e9900 100644 --- a/app/command_set_manager.py +++ b/app/command_set_manager.py @@ -11,7 +11,11 @@ enforce_store_transition, validate_command_set, ) -from .startup_commands import normalize_startup_commands +from .connection_storage_policy import ConnectionStorageLimitError +from .startup_commands import ( + normalize_startup_commands, + validate_command_parameters, +) from .storage_utils import ( atomic_write_json, load_json_migrated, @@ -23,6 +27,7 @@ COMMAND_SET_NAME_MAX = 128 SUDO_TOKEN_BOUNDARIES = ' \t;&|()<>' +_REFERENCE_USAGE_DETAIL_LIMIT = 20 def _prefix_commands_with_sudo(value): @@ -342,16 +347,17 @@ def _normalize_steps(steps, commands): normalized_step = {'type': 'library', 'command_id': command_id} if 'parameters_override' in raw_step: override = raw_step['parameters_override'] - if override is not None and not isinstance(override, str): - return None, None, f'Command set step {position} has invalid parameters' - if isinstance(override, str) and '\x00' in override: - return None, None, 'Commands cannot contain NUL bytes' + if override is not None: + parameter_error = validate_command_parameters(override) + if parameter_error: + return None, None, parameter_error normalized_step['parameters_override'] = override parameters = normalized_step.get('parameters_override') if parameters is None: parameters = command.get('parameters', '') - if not isinstance(parameters, str): - return None, None, f'Command set step {position} has invalid parameters' + parameter_error = validate_command_parameters(parameters) + if parameter_error: + return None, None, parameter_error text = command['command'] + (f' {parameters}' if parameters else '') normalized_steps.append(normalized_step) resolved_parts.append(text) @@ -632,36 +638,52 @@ def load_command_sets_with_resolution(user_id): ) -def _get_command_usage_with_coordinator_held(user_id, command_id): - """Read references while the caller owns ``command-config``.""" +def _get_command_usage_summary_with_coordinator_held(user_id, command_id): + """Count all references and retain only bounded response-safe details.""" with storage_lock(f'command-sets:{user_id}'): command_sets, error = _load_command_sets_with_lock_held(user_id) if error: - return None, error + return 0, set(), [], error + usage_count = 0 + usage_types = set() usages = [] for command_set in command_sets: steps = command_set.get('steps', []) if isinstance(command_set, dict) else [] if any(step.get('type') == 'library' and step.get('command_id') == command_id for step in steps if isinstance(step, dict)): - usages.append({ - 'id': command_set.get('id'), - 'name': safe_reference_name(command_set.get('name')), - 'type': 'command_set', - }) + usage_count += 1 + usage_types.add('command_set') + if len(usages) < _REFERENCE_USAGE_DETAIL_LIMIT: + usages.append({ + 'id': safe_reference_name(command_set.get('id')), + 'name': safe_reference_name(command_set.get('name')), + 'type': 'command_set', + }) with storage_lock(f'profiles:{user_id}'): profiles, error = _load_profile_references(user_id) if error: - return None, error + return 0, set(), [], error for profile in profiles: if (isinstance(profile, dict) and profile.get('command_id') == command_id): - usages.append({ - 'id': profile.get('id'), - 'name': safe_reference_name(profile.get('name')), - 'type': 'profile', - }) - return usages, None + usage_count += 1 + usage_types.add('profile') + if len(usages) < _REFERENCE_USAGE_DETAIL_LIMIT: + usages.append({ + 'id': safe_reference_name(profile.get('id')), + 'name': safe_reference_name(profile.get('name')), + 'type': 'profile', + }) + return usage_count, usage_types, usages, None + + +def _get_command_usage_with_coordinator_held(user_id, command_id): + """Read bounded reference details while the coordinator is held.""" + _count, _types, usages, error = ( + _get_command_usage_summary_with_coordinator_held(user_id, command_id) + ) + return (None, error) if error else (usages, None) def get_command_usage(user_id, command_id): @@ -672,10 +694,13 @@ def get_command_usage(user_id, command_id): def _load_profile_references(user_id): from . import profile_manager - path = profile_manager.get_user_profiles_file(user_id) - if path is None: - return [], None - return profile_manager._load_profiles_with_lock_held(user_id), None + try: + return ( + profile_manager._load_profiles_for_read_with_lock_held(user_id), + None, + ) + except ConnectionStorageLimitError as exc: + return None, str(exc) def delete_command_set(user_id, command_set_id): @@ -684,17 +709,27 @@ def delete_command_set(user_id, command_set_id): profiles, error = _load_profile_references(user_id) if error: return False, error, [] - usages = [ - safe_reference_name(profile.get('name')) - for profile in profiles + usage_count = 0 + usages = [] + for profile in profiles: if ( - isinstance(profile, dict) - and profile.get('command_set_id') == command_set_id + not isinstance(profile, dict) + or profile.get('command_set_id') != command_set_id + ): + continue + usage_count += 1 + if len(usages) < _REFERENCE_USAGE_DETAIL_LIMIT: + usages.append(safe_reference_name(profile.get('name'))) + if usage_count: + noun = 'profile' if usage_count == 1 else 'profiles' + details = '' + if usage_count > len(usages): + details = f' (showing first {len(usages)})' + return ( + False, + f'Command set is used by {usage_count} {noun}{details}', + usages, ) - ] - if usages: - noun = 'profile' if len(usages) == 1 else 'profiles' - return False, f'Command set is used by {len(usages)} {noun}', usages with storage_lock(f'command-sets:{user_id}'): command_sets, error = _load_command_sets_with_lock_held(user_id) diff --git a/app/connection_pool.py b/app/connection_pool.py index 2a6748e9..2b09e1c3 100644 --- a/app/connection_pool.py +++ b/app/connection_pool.py @@ -264,8 +264,8 @@ def get_sftp_client(self, connection_id): operation_timeout=config.SFTP_OPERATION_TIMEOUT, ) return sftp, None - except Exception as e: - return None, f"Failed to open SFTP channel: {str(e)}" + except Exception: + return None, "Failed to open SFTP channel" def close_connection(self, connection_id): """ diff --git a/app/connection_storage_policy.py b/app/connection_storage_policy.py new file mode 100644 index 00000000..2f149973 --- /dev/null +++ b/app/connection_storage_policy.py @@ -0,0 +1,244 @@ +"""Prospective quotas for saved connection and jump-host metadata.""" + +from hashlib import sha256 +import hmac +import json +from pathlib import Path +import re + +import config + + +class ConnectionStorageLimitError(ValueError): + """A saved-connection mutation would grow beyond its resource budget.""" + + +PROFILE_NAME_MAX_BYTES = 512 +PROFILE_HOST_MAX_BYTES = 1024 +PROFILE_USERNAME_MAX_BYTES = 128 +PROFILE_GROUP_MAX_BYTES = 256 +PROFILE_REFERENCE_MAX_BYTES = 128 +PROFILE_STARTUP_MAX_BYTES = 64 * 1024 +JUMP_HOST_NAME_MAX_BYTES = 512 +_RECOVERY_SELECTOR_PATTERN = re.compile( + r'r1:(0|[1-9][0-9]{0,9}):([0-9a-f]{64})' +) +_RECOVERY_SELECTOR_DOMAIN = b'webssh-connection-recovery-selector-v1\0' + + +def _error(message): + raise ConnectionStorageLimitError( + f'Connection storage quota exceeded: {message}' + ) + + +def utf8_size(value): + try: + return len(value.encode('utf-8')) + except UnicodeEncodeError: + _error('text is not valid UTF-8') + + +def bounded_text(value, maximum, label, legacy=None): + if not isinstance(value, str): + _error(f'{label} must be text') + size = utf8_size(value) + if size <= maximum: + return + if isinstance(legacy, str) and size <= utf8_size(legacy): + return + _error(f'{label} is too large') + + +def validate_profile(profile, legacy=None): + """Enforce UTF-8 field budgets on one changed profile.""" + legacy = legacy if isinstance(legacy, dict) else {} + for field, maximum, label in ( + ('id', PROFILE_REFERENCE_MAX_BYTES, 'profile id'), + ('name', PROFILE_NAME_MAX_BYTES, 'profile name'), + ('host', PROFILE_HOST_MAX_BYTES, 'profile host'), + ('username', PROFILE_USERNAME_MAX_BYTES, 'profile username'), + ('group', PROFILE_GROUP_MAX_BYTES, 'profile group'), + ('key_id', PROFILE_REFERENCE_MAX_BYTES, 'key reference'), + ('jump_host_id', PROFILE_REFERENCE_MAX_BYTES, 'jump-host reference'), + ('command_id', PROFILE_REFERENCE_MAX_BYTES, 'command reference'), + ('command_set_id', PROFILE_REFERENCE_MAX_BYTES, 'command-set reference'), + ('startup_commands', PROFILE_STARTUP_MAX_BYTES, 'startup commands'), + ( + 'parameters_override', + PROFILE_STARTUP_MAX_BYTES, + 'command parameters', + ), + ): + value = profile.get(field) + if value is None: + continue + bounded_text(value, maximum, label, legacy.get(field)) + + +def validate_jump_host(jump_host, legacy=None): + """Enforce UTF-8 field budgets on one changed jump host.""" + legacy = legacy if isinstance(legacy, dict) else {} + for field, maximum, label in ( + ('id', PROFILE_REFERENCE_MAX_BYTES, 'jump-host id'), + ('name', JUMP_HOST_NAME_MAX_BYTES, 'jump-host name'), + ('host', PROFILE_HOST_MAX_BYTES, 'jump-host host'), + ('username', PROFILE_USERNAME_MAX_BYTES, 'jump-host username'), + ('key_id', PROFILE_REFERENCE_MAX_BYTES, 'key reference'), + ): + value = jump_host.get(field) + if value is None: + continue + bounded_text(value, maximum, label, legacy.get(field)) + + +def _file_size(path): + try: + return Path(path).stat().st_size + except FileNotFoundError: + return 0 + + +def _serialize_document(document, *, compact=False): + try: + kwargs = {'separators': (',', ':')} if compact else {'indent': 2} + return json.dumps(document, **kwargs).encode('utf-8') + except (TypeError, ValueError, UnicodeEncodeError) as exc: + raise ConnectionStorageLimitError( + 'Connection storage quota exceeded: data is not serializable' + ) from exc + + +def enforce_store_read_limit(path, *, record_count=None, maximum_count=None): + """Reject oversized legacy stores before normal listing or launch paths.""" + if _file_size(path) > config.CONNECTION_STORE_MAX_BYTES: + _error('stored data exceeds its byte limit') + if ( + record_count is not None + and maximum_count is not None + and record_count > maximum_count + ): + _error(f'more than {maximum_count} stored records are not allowed') + + +def enforce_store_recovery_limit(path, *, record_count=None): + """Bound legacy recovery before and after JSON deserialization. + + Recovery deliberately permits a store larger than the normal read limit, + but it must never become an unbounded parsing path. Call once before the + load for the byte ceiling and again with the decoded record count. + """ + if _file_size(path) > config.CONNECTION_STORE_RECOVERY_MAX_BYTES: + _error('stored data exceeds its recovery byte limit') + if ( + record_count is not None + and record_count > config.CONNECTION_STORE_RECOVERY_MAX_RECORDS + ): + _error( + 'more than ' + f'{config.CONNECTION_STORE_RECOVERY_MAX_RECORDS} recovery records ' + 'are not allowed' + ) + + +def recovery_record_selector(scope, index, record): + """Return a stable, opaque selector for one record at one exact ordinal.""" + if not isinstance(scope, str) or not scope: + raise ValueError('recovery selector scope is required') + if type(index) is not int or index < 0: + raise ValueError('recovery selector index is invalid') + canonical = json.dumps( + record, + ensure_ascii=True, + separators=(',', ':'), + sort_keys=True, + ).encode('utf-8') + digest = sha256( + _RECOVERY_SELECTOR_DOMAIN + + scope.encode('utf-8') + + b'\0' + + str(index).encode('ascii') + + b'\0' + + canonical + ).hexdigest() + return f'r1:{index}:{digest}' + + +def resolve_recovery_record_selector(scope, records, selector): + """Resolve a selector only when its current ordinal and content still match.""" + if not isinstance(selector, str): + return None + match = _RECOVERY_SELECTOR_PATTERN.fullmatch(selector) + if match is None: + return None + index = int(match.group(1)) + if index >= len(records): + return None + expected = recovery_record_selector(scope, index, records[index]) + return index if hmac.compare_digest(expected, selector) else None + + +def enforce_store_transition( + *, + path, + other_path, + prospective_document, + prospective_count, + previous_count, + maximum_count, + previous_document=None, + compact=False, +): + """Return the exact approved payload for a prospective store transition. + + Ordinary writes retain the human-readable representation when it satisfies + every limit. A compact representation is an exact fallback for stores + produced by recovery or another valid JSON writer; formatting alone must + not turn a safe shrink into a rejected or self-quarantining mutation. + """ + path = Path(path) + current_size = _file_size(path) + other_size = _file_size(other_path) + if prospective_count > maximum_count and ( + previous_count is None or prospective_count > previous_count + ): + _error(f'more than {maximum_count} records are not allowed') + + require_no_growth = previous_document is not None + candidates = (True,) if compact else (False, True) + last_error = None + for compact_candidate in candidates: + prospective_payload = _serialize_document( + prospective_document, + compact=compact_candidate, + ) + prospective_size = len(prospective_payload) + try: + if ( + previous_document is not None + and prospective_size > config.CONNECTION_STORE_RECOVERY_MAX_BYTES + ): + _error( + 'one connection store would exceed its recovery byte limit' + ) + if require_no_growth and prospective_size > current_size: + _error('recovery deletion would grow its connection store') + if ( + prospective_size > config.CONNECTION_STORE_MAX_BYTES + and prospective_size > current_size + ): + _error('one connection store would exceed its byte limit') + if ( + prospective_size + other_size + > config.CONNECTION_CONFIG_MAX_BYTES + and prospective_size > current_size + ): + _error('combined connection data would exceed its byte limit') + except ConnectionStorageLimitError as exc: + last_error = exc + continue + return prospective_payload + + if last_error is not None: + raise last_error + _error('data is not serializable') diff --git a/app/decorators.py b/app/decorators.py index 8a1a4478..fcab1857 100644 --- a/app/decorators.py +++ b/app/decorators.py @@ -1,6 +1,6 @@ from functools import wraps -from flask_socketio import disconnect, emit -from flask import request, abort +from flask_socketio import emit +from flask import abort, current_app, request from flask_login import current_user import config from .auth import get_user_from_socket, login_manager @@ -10,9 +10,17 @@ def _socket_authentication_is_valid(user): """Validate that the browser assurance session owns the socket user.""" from .auth_assurance import current_authentication_session + from .ldap_session import ldap_revocation_pending auth_session = current_authentication_session() - return auth_session is not None and auth_session.user_id == user.id + return ( + auth_session is not None + and auth_session.user_id == user.id + and not ( + user.is_ldap_managed + and ldap_revocation_pending(current_app, user.id) + ) + ) def admin_required(f): @@ -72,7 +80,10 @@ def decorated_function(*args, **kwargs): user = get_user_from_socket(socket_sid) if not user: log_warning("Unauthorized socket event attempt", event=f.__name__, sid=socket_sid) - disconnect() + from . import socketio + from .socket_events import disconnect_socket_transport + + disconnect_socket_transport(socketio.server, socket_sid) return if not _socket_authentication_is_valid(user): payload = { @@ -87,7 +98,10 @@ def decorated_function(*args, **kwargs): sid=socket_sid, ) emit('error', payload) - disconnect() + from . import socketio + from .socket_events import disconnect_socket_transport + + disconnect_socket_transport(socketio.server, socket_sid) return payload kwargs['current_user'] = user return f(*args, **kwargs) diff --git a/app/factor_bootstrap.py b/app/factor_bootstrap.py new file mode 100644 index 00000000..d707dd48 --- /dev/null +++ b/app/factor_bootstrap.py @@ -0,0 +1,124 @@ +"""Out-of-band bootstrap for a GitHub-provisioned user's first factor.""" + +from datetime import datetime, timedelta, timezone +import hashlib +import re +import secrets + +from .models import FactorBootstrapToken, TOTPAuthenticator, as_naive_utc, db + + +FACTOR_BOOTSTRAP_ACTIONS = frozenset({ + 'passkey.enroll', + 'totp.enroll', +}) +_TOKEN_TTL = timedelta(minutes=10) +_TOKEN_DOMAIN = b'webssh-factor-bootstrap-v1\x00' +_TOKEN_PATTERN = re.compile(r'[A-Za-z0-9_-]{40,128}') + + +class FactorBootstrapError(RuntimeError): + """The requested initial-factor bootstrap is not eligible.""" + + +def _token_hash(token): + return hashlib.sha256( + _TOKEN_DOMAIN + str(token).encode('utf-8') + ).hexdigest() + + +def _normalize_action(action): + action = str(action or '').strip() + if action not in FACTOR_BOOTSTRAP_ACTIONS: + raise FactorBootstrapError('Unsupported factor enrollment action.') + return action + + +def user_is_eligible(user, action): + """Allow only the first durable factor for an active GitHub-only user.""" + try: + action = _normalize_action(action) + except FactorBootstrapError: + return False + return bool( + user is not None + and user.id is not None + and not user.is_locked + and not user.mfa_enabled + and user.is_github_managed + and user.github_identity is not None + and user.webauthn_credentials.count() == 0 + and TOTPAuthenticator.query.filter_by( + user_id=user.id, + active=True, + ).count() == 0 + and action in FACTOR_BOOTSTRAP_ACTIONS + ) + + +def issue_factor_bootstrap(user, action, *, now=None): + """Issue one short-lived code, replacing all prior codes for the user.""" + action = _normalize_action(action) + if not user_is_eligible(user, action): + raise FactorBootstrapError( + 'Only a factorless GitHub-provisioned account is eligible.' + ) + issued_at = as_naive_utc(now or datetime.now(timezone.utc)) + token = secrets.token_urlsafe(32) + FactorBootstrapToken.query.filter_by(user_id=user.id).delete( + synchronize_session=False + ) + row = FactorBootstrapToken( + token_hash=_token_hash(token), + user_id=user.id, + auth_generation=int(user.auth_generation or 0), + action=action, + created_at=issued_at, + expires_at=issued_at + _TOKEN_TTL, + ) + db.session.add(row) + db.session.commit() + return token, row.expires_at + + +def has_live_factor_bootstrap(user, action, *, now=None): + """Return whether this user/action currently has a redeemable code.""" + if not user_is_eligible(user, action): + return False + cutoff = as_naive_utc(now or datetime.now(timezone.utc)) + return FactorBootstrapToken.query.filter( + FactorBootstrapToken.user_id == user.id, + FactorBootstrapToken.auth_generation + == int(user.auth_generation or 0), + FactorBootstrapToken.action == action, + FactorBootstrapToken.consumed_at.is_(None), + FactorBootstrapToken.expires_at > cutoff, + ).first() is not None + + +def consume_factor_bootstrap(user, action, token, *, now=None): + """Atomically redeem one code for its exact user and enrollment action.""" + if ( + not isinstance(token, str) + or _TOKEN_PATTERN.fullmatch(token) is None + or not user_is_eligible(user, action) + ): + return False + cutoff = as_naive_utc(now or datetime.now(timezone.utc)) + updated = FactorBootstrapToken.query.filter( + FactorBootstrapToken.token_hash == _token_hash(token), + FactorBootstrapToken.user_id == user.id, + FactorBootstrapToken.auth_generation + == int(user.auth_generation or 0), + FactorBootstrapToken.action == action, + FactorBootstrapToken.consumed_at.is_(None), + FactorBootstrapToken.expires_at > cutoff, + ).update( + {FactorBootstrapToken.consumed_at: cutoff}, + synchronize_session=False, + ) + if updated != 1: + db.session.rollback() + return False + db.session.commit() + return True diff --git a/app/file_backend.py b/app/file_backend.py index 9180e898..1d64dc1f 100644 --- a/app/file_backend.py +++ b/app/file_backend.py @@ -18,6 +18,18 @@ }) +class FileSourceChanged(RuntimeError): + """A previously enumerated remote object no longer matches its path.""" + + public_code = 'SOURCE_CHANGED' + + +class FileOperationCancelled(RuntimeError): + """A backend stopped promptly because the caller cancelled its work.""" + + public_code = 'CANCELLED' + + @dataclass(frozen=True, slots=True) class FileReaderLease: """One readable remote object and metadata obtained from that handle.""" @@ -118,6 +130,14 @@ def normalize_path(self, path: str) -> str: def list_directory(self, source: 'ResolvedFileSource', path: str) -> Any: ... + def open_directory_listing( + self, + source: 'ResolvedFileSource', + path: str, + ) -> Any: + """Open one bounded, incremental directory enumeration.""" + ... + def stat( self, source: 'ResolvedFileSource', diff --git a/app/file_service.py b/app/file_service.py index 2b5be067..2259d729 100644 --- a/app/file_service.py +++ b/app/file_service.py @@ -1,5 +1,14 @@ """Authorization and capability boundary for all file source operations.""" +import hashlib +import hmac +import re +import secrets +import time +from threading import Event, Lock, RLock, Timer + +import config + from .file_backend import FileWriteOutcome from .file_sources import ( FileCapability, @@ -16,6 +25,8 @@ class FileService: def __init__(self, resolver): self.resolver = resolver + self._directory_snapshots = {} + self._directory_snapshot_lock = Lock() def resolve(self, source_id, user_id, capability): source = self.resolver.resolve(source_id, user_id) @@ -38,6 +49,583 @@ def list_directory(self, source_id, *, user_id, path): source = self.resolve(source_id, user_id, FileCapability.LIST) return source.backend.list_directory(source, path) + def list_directory_page( + self, + source_id, + *, + user_id, + path, + cursor=0, + client_id=None, + request_id=None, + ): + source = self.resolve(source_id, user_id, FileCapability.LIST) + if cursor != 0: + return self._continue_directory_snapshot( + source, + user_id=user_id, + path=path, + cursor=cursor, + client_id=client_id, + ) + + opener = getattr(source.backend, 'open_directory_listing', None) + if not callable(opener): + return None, 'Directory pagination unavailable', None + + owner_key = str(user_id) + snapshot_id, state = self._reserve_directory_snapshot( + source, + owner_key=owner_key, + path=path, + client_id=client_id, + request_id=request_id, + ) + if state is None: + return None, 'Too many active directory listings', None + + try: + listing, error = opener(source, path) + with state['lock']: + state['listing'] = listing + if error or listing is None: + self._retire_directory_snapshot(snapshot_id, state) + return ( + None, + error or 'Directory pagination unavailable', + None, + ) + with self._directory_snapshot_lock: + cancelled = ( + self._directory_snapshots.get(snapshot_id) is not state + or state['status'] != 'opening' + ) + if cancelled: + self._retire_directory_snapshot(snapshot_id, state) + return None, 'Directory listing cancelled', None + + page_size = state['page_size'] + page, error, has_more = listing.read_page(page_size) + except Exception: + self._retire_directory_snapshot(snapshot_id, state) + raise + + if error: + self._retire_directory_snapshot(snapshot_id, state) + return None, error, None + if not isinstance(page, list) or len(page) > page_size: + self._retire_directory_snapshot(snapshot_id, state) + return None, 'Invalid directory response', None + if not has_more: + self._retire_directory_snapshot(snapshot_id, state) + return page, None, None + if not page: + self._retire_directory_snapshot(snapshot_id, state) + return None, 'Invalid directory response', None + + now = time.monotonic() + with self._directory_snapshot_lock: + if ( + self._directory_snapshots.get(snapshot_id) is not state + or state['status'] != 'opening' + ): + cancelled = True + else: + cancelled = False + state['status'] = 'active' + state['next_offset'] = len(page) + state['last_used'] = now + self._arm_directory_snapshot_locked(snapshot_id, state) + if cancelled: + self._retire_directory_snapshot(snapshot_id, state) + return None, 'Directory listing cancelled', None + + return ( + page, + None, + self._directory_cursor( + snapshot_id, + state['next_offset'], + state['signing_key'], + ), + ) + + def _reserve_directory_snapshot( + self, + source, + *, + owner_key, + path, + client_id, + request_id, + ): + snapshot_id = secrets.token_urlsafe(18) + state = { + 'owner': owner_key, + 'source_id': str(source.source_id), + 'path': str(path), + 'handle_id': str(source.handle_id), + 'backend_id': id(source.backend), + 'client_id': '' if client_id is None else str(client_id), + 'request_id': ( + request_id + if self._valid_directory_request_id(request_id) + else '' + ), + 'listing': None, + 'page_size': config.REMOTE_LISTING_PAGE_SIZE, + 'next_offset': 0, + 'signing_key': secrets.token_bytes(32), + 'last_used': time.monotonic(), + 'status': 'opening', + 'cancel_waiter': False, + 'close_started': False, + 'lock': RLock(), + 'closed': Event(), + 'timer': None, + } + + while True: + retired = [] + with self._directory_snapshot_lock: + retired = self._prune_directory_snapshots_locked( + time.monotonic() + ) + if not retired: + retired = self._retire_for_directory_capacity_locked( + owner_key + ) + if not retired: + owner_count = sum( + candidate['owner'] == owner_key + for candidate in self._directory_snapshots.values() + ) + if ( + owner_count + >= config.REMOTE_LISTING_SNAPSHOT_MAX_PER_USER + or len(self._directory_snapshots) + >= config.REMOTE_LISTING_SNAPSHOT_MAX_STATES + ): + return None, None + while snapshot_id in self._directory_snapshots: + snapshot_id = secrets.token_urlsafe(18) + self._directory_snapshots[snapshot_id] = state + return snapshot_id, state + self._close_directory_states(retired) + + @staticmethod + def _directory_cursor(snapshot_id, offset, signing_key): + message = f'{snapshot_id}:{offset}'.encode('ascii') + signature = hmac.new( + signing_key, + message, + hashlib.sha256, + ).hexdigest()[:32] + return f'v1.{snapshot_id}.{offset}.{signature}' + + @staticmethod + def _parse_directory_cursor(cursor): + if ( + not isinstance(cursor, str) + or not 1 <= len(cursor) <= 160 + ): + return None + match = re.fullmatch( + r'v1\.([A-Za-z0-9_-]{16,64})\.([1-9][0-9]{0,7})\.([0-9a-f]{32})', + cursor, + ) + if match is None: + return None + return match.group(1), int(match.group(2)), match.group(3) + + @staticmethod + def _valid_directory_request_id(request_id): + return ( + isinstance(request_id, str) + and re.fullmatch(r'[A-Za-z0-9:._-]{1,128}', request_id) + is not None + ) + + def _continue_directory_snapshot( + self, + source, + *, + user_id, + path, + cursor, + client_id, + ): + parsed = self._parse_directory_cursor(cursor) + if parsed is None: + return None, 'Invalid or expired directory cursor', None + snapshot_id, offset, supplied_signature = parsed + now = time.monotonic() + with self._directory_snapshot_lock: + retired = self._prune_directory_snapshots_locked(now) + self._close_directory_states(retired) + with self._directory_snapshot_lock: + state = self._directory_snapshots.get(snapshot_id) + if state is None: + return None, 'Invalid or expired directory cursor', None + + retire_result = None + with state['lock']: + with self._directory_snapshot_lock: + if ( + self._directory_snapshots.get(snapshot_id) is not state + or state['status'] != 'active' + ): + return None, 'Invalid or expired directory cursor', None + expected_cursor = self._directory_cursor( + snapshot_id, + offset, + state['signing_key'], + ) + expected_signature = expected_cursor.rsplit('.', 1)[-1] + if not hmac.compare_digest( + supplied_signature, + expected_signature, + ): + return None, 'Invalid or expired directory cursor', None + if ( + state['owner'] != str(user_id) + or state['source_id'] != str(source.source_id) + or state['path'] != str(path) + or state['handle_id'] != str(source.handle_id) + or state['backend_id'] != id(source.backend) + or state['client_id'] != ( + '' if client_id is None else str(client_id) + ) + or offset != state['next_offset'] + ): + return None, 'Invalid or expired directory cursor', None + + page, error, has_more = state['listing'].read_page( + state['page_size'] + ) + if ( + error + or not isinstance(page, list) + or len(page) > state['page_size'] + or has_more and not page + ): + retire_result = ( + None, + error or 'Invalid directory response', + None, + ) + elif not has_more: + retire_result = (page, None, None) + else: + state['next_offset'] += len(page) + state['last_used'] = now + with self._directory_snapshot_lock: + if ( + self._directory_snapshots.get(snapshot_id) is not state + ): + return ( + None, + 'Invalid or expired directory cursor', + None, + ) + if state['status'] == 'active': + self._arm_directory_snapshot_locked(snapshot_id, state) + elif state['status'] != 'closing': + return ( + None, + 'Invalid or expired directory cursor', + None, + ) + next_cursor = self._directory_cursor( + snapshot_id, + state['next_offset'], + state['signing_key'], + ) + if retire_result is not None: + with self._directory_snapshot_lock: + if ( + self._directory_snapshots.get(snapshot_id) is state + and state['status'] == 'active' + ): + state['status'] = 'closing' + # This continuation owns the synchronous close. Cancel + # retransmissions should acknowledge it, not compete + # for the backend handle or add another waiter. + state['cancel_waiter'] = True + if retire_result is not None: + # Do not retain the per-state RLock while backend close may block. + # This lets duplicate authenticated cancellations observe the one + # elected closer and return immediately. + self._retire_directory_snapshot(snapshot_id, state) + return retire_result + return page, None, next_cursor + + def _prune_directory_snapshots_locked(self, now): + expiry = float(config.REMOTE_LISTING_SNAPSHOT_TTL_SECONDS) + retired = [] + for snapshot_id, state in tuple(self._directory_snapshots.items()): + if ( + state['status'] == 'active' + and now - state['last_used'] >= expiry + ): + state['status'] = 'closing' + retired.append((snapshot_id, state)) + return retired + + def _retire_for_directory_capacity_locked(self, owner_key): + per_user = config.REMOTE_LISTING_SNAPSHOT_MAX_PER_USER + owned_count = sum( + state['owner'] == owner_key + for state in self._directory_snapshots.values() + ) + if owned_count >= per_user: + owned = [ + (state['last_used'], snapshot_id, state) + for snapshot_id, state in self._directory_snapshots.items() + if state['owner'] == owner_key + and state['status'] == 'active' + ] + if owned: + _last_used, snapshot_id, state = min(owned) + state['status'] = 'closing' + return [(snapshot_id, state)] + return [] + + maximum = config.REMOTE_LISTING_SNAPSHOT_MAX_STATES + if len(self._directory_snapshots) >= maximum: + candidates = [ + (state['last_used'], snapshot_id, state) + for snapshot_id, state in self._directory_snapshots.items() + if state['owner'] == owner_key + and state['status'] == 'active' + ] + if candidates: + _last_used, snapshot_id, state = min(candidates) + state['status'] = 'closing' + return [(snapshot_id, state)] + return [] + + def _arm_directory_snapshot_locked(self, snapshot_id, state): + timer = state.get('timer') + if timer is not None: + timer.cancel() + expected_last_used = state['last_used'] + timer = Timer( + config.REMOTE_LISTING_SNAPSHOT_TTL_SECONDS, + self._expire_directory_snapshot, + args=(snapshot_id, state, expected_last_used), + ) + timer.daemon = True + state['timer'] = timer + timer.start() + + def _expire_directory_snapshot( + self, + snapshot_id, + state, + expected_last_used, + ): + with self._directory_snapshot_lock: + if ( + self._directory_snapshots.get(snapshot_id) is not state + or state['last_used'] != expected_last_used + or state['status'] != 'active' + ): + return + state['status'] = 'closing' + self._close_directory_state(snapshot_id, state) + + def _retire_directory_snapshot(self, snapshot_id, state): + with self._directory_snapshot_lock: + if self._directory_snapshots.get(snapshot_id) is not state: + return + state['status'] = 'closing' + self._close_directory_state(snapshot_id, state) + + def cancel_directory_snapshot( + self, + cursor, + *, + user_id, + source_id, + client_id, + ): + """Close exactly one caller-owned paginated directory snapshot.""" + parsed = self._parse_directory_cursor(cursor) + if parsed is None: + return False + snapshot_id, offset, supplied_signature = parsed + with self._directory_snapshot_lock: + state = self._directory_snapshots.get(snapshot_id) + if state is None: + return False + + should_close = False + wait_for_close = False + # Cursor signing material and ownership are immutable after state + # publication. Validate and elect the closer under the registry lock, + # without queueing every duplicate behind a remote read that owns the + # per-state lock. + with self._directory_snapshot_lock: + if self._directory_snapshots.get(snapshot_id) is not state: + return False + expected_cursor = self._directory_cursor( + snapshot_id, + offset, + state['signing_key'], + ) + expected_signature = expected_cursor.rsplit('.', 1)[-1] + valid = ( + hmac.compare_digest( + supplied_signature, + expected_signature, + ) + and state['owner'] == str(user_id) + and state['source_id'] == str(source_id) + and state['client_id'] == ( + '' if client_id is None else str(client_id) + ) + ) + if not valid: + return False + if state['status'] == 'active': + state['status'] = 'closing' + if not state['cancel_waiter']: + state['cancel_waiter'] = True + should_close = True + elif state['status'] in {'cancelled', 'closing'}: + # Preserve the synchronous close guarantee for one elected + # caller only. Duplicate retransmissions acknowledge the + # already-owned cancellation immediately instead of each + # retaining an Engine.IO worker until backend I/O returns. + if not state['cancel_waiter']: + state['cancel_waiter'] = True + wait_for_close = True + else: + return False + + if should_close: + self._close_directory_state(snapshot_id, state) + elif wait_for_close: + state['closed'].wait() + return True + + def cancel_directory_request( + self, + request_id, + *, + user_id, + source_id, + client_id, + ): + """Cancel page zero by its exact caller-owned request identity.""" + if not self._valid_directory_request_id(request_id): + return False + owner_key = str(user_id) + source_key = str(source_id) + client_key = '' if client_id is None else str(client_id) + retired = [] + wait_for_close = [] + matched = False + with self._directory_snapshot_lock: + for snapshot_id, state in tuple( + self._directory_snapshots.items() + ): + if ( + state.get('request_id') != request_id + or state['owner'] != owner_key + or state['source_id'] != source_key + or state['client_id'] != client_key + ): + continue + if state['status'] == 'opening': + state['status'] = 'cancelled' + matched = True + if not state['cancel_waiter']: + state['cancel_waiter'] = True + wait_for_close.append(state) + elif state['status'] == 'active': + state['status'] = 'closing' + retired.append((snapshot_id, state)) + matched = True + state['cancel_waiter'] = True + elif state['status'] in {'cancelled', 'closing'}: + matched = True + if not state['cancel_waiter']: + state['cancel_waiter'] = True + wait_for_close.append(state) + self._close_directory_states(retired) + # An opening listing cannot safely be closed while its backend owns + # open_directory_listing() or the initial read_page(). Wait on only + # the exactly authorized states so a FIFO replacement cannot acquire + # another backend channel before retirement has completed. + for state in wait_for_close: + state['closed'].wait() + return matched + + def _close_directory_state(self, snapshot_id, state): + with state['lock']: + if state['close_started']: + return + state['close_started'] = True + state['cancel_waiter'] = True + timer = state.get('timer') + if timer is not None: + try: + timer.cancel() + except Exception: + pass + state['timer'] = None + listing = state.get('listing') + state['listing'] = None + try: + # The status transition happened before this point, so no new page + # read can start. Close outside the state lock: duplicate cancel + # requests can now observe the elected closer and return without + # accumulating blocked worker threads behind slow backend I/O. + if listing is not None: + listing.close() + except Exception: + pass + finally: + with self._directory_snapshot_lock: + if self._directory_snapshots.get(snapshot_id) is state: + self._directory_snapshots.pop(snapshot_id, None) + state['closed'].set() + + def _close_directory_states(self, states): + for snapshot_id, state in states: + self._close_directory_state(snapshot_id, state) + + def discard_directory_snapshots( + self, + *, + user_id=None, + source_id=None, + client_id=None, + ): + """Drop cached directory metadata after a source lifecycle change.""" + owner_key = None if user_id is None else str(user_id) + source_key = None if source_id is None else str(source_id) + client_key = None if client_id is None else str(client_id) + retired = [] + with self._directory_snapshot_lock: + for snapshot_id, state in tuple( + self._directory_snapshots.items() + ): + if owner_key is not None and state['owner'] != owner_key: + continue + if source_key is not None and state['source_id'] != source_key: + continue + if client_key is not None and state['client_id'] != client_key: + continue + if state['status'] == 'opening': + state['status'] = 'cancelled' + elif state['status'] == 'active': + state['status'] = 'closing' + retired.append((snapshot_id, state)) + self._close_directory_states(retired) + def get_home_directory(self, source_id, *, user_id): source = self.resolve(source_id, user_id, FileCapability.LIST) return source.backend.get_home_directory(source) diff --git a/app/github_auth_routes.py b/app/github_auth_routes.py index d4f7d098..4a9323a9 100644 --- a/app/github_auth_routes.py +++ b/app/github_auth_routes.py @@ -16,7 +16,6 @@ from .audit_logger import log_rate_limit_exceeded, log_security_event from .auth import ( check_rate_limit, - check_reauth_rate_limit, user_creation_transaction, validate_new_user, ) @@ -124,28 +123,16 @@ def github_step_up_start(): return jsonify({'error': 'Invalid request'}), 400 token = data.get('intent') try: - intent = account_step_up_intent(token, current_authentication_session()) + account_step_up_intent(token, current_authentication_session()) except StepUpError: return jsonify({'error': 'Step-up authentication failed'}), 403 - if current_user.github_identity is None: - return jsonify({'error': 'Step-up authentication failed'}), 403 - client_ip = request.remote_addr or 'unknown' - if config.RATELIMIT_ENABLED and check_reauth_rate_limit( - current_user.id, - client_ip, - 'account_step_up_github_start', - config.RATELIMIT_REAUTH, - ): - log_rate_limit_exceeded('account_step_up_github_start', client_ip) - response = jsonify({'error': 'Step-up authentication failed'}) - response.status_code = 429 - response.headers['Retry-After'] = '60' - return response - return jsonify({'authorization_url': _begin_authorization( - purpose='step_up', - step_up_intent_id=intent.id, - continuation=data.get('continuation') or '/security', - )}) + log_security_event( + 'ACCOUNT_STEP_UP_REJECTED', + user=current_user.username, + method='github', + reason='fresh_authentication_unavailable', + ) + return jsonify({'error': 'Step-up authentication failed'}), 403 def _provision_username(login, github_user_id): @@ -240,32 +227,13 @@ def _complete_link(intent, profile): def _complete_step_up(intent, profile): - from .auth_assurance import current_authentication_session - from .step_up import approve_account_step_up_intent_by_id, StepUpError - - auth_session = current_authentication_session() - identity = current_user.github_identity if current_user.is_authenticated else None - if ( - auth_session is None - or identity is None - or identity.github_user_id != profile.user_id - ): - return jsonify({'error': 'Step-up authentication failed'}), 403 - try: - approved = approve_account_step_up_intent_by_id( - intent.step_up_intent_id, - auth_session, - assurance=AssuranceLevel.BASIC, - method='github', - ) - except StepUpError: - return jsonify({'error': 'Step-up authentication failed'}), 403 log_security_event( - 'ACCOUNT_STEP_UP_GRANTED', user=current_user.username, - method='github', action=approved.action, - assurance=AssuranceLevel.BASIC.value, result='approved', + 'ACCOUNT_STEP_UP_REJECTED', + user=(current_user.username if current_user.is_authenticated else None), + method='github', + reason='fresh_authentication_unavailable', ) - return redirect(intent.continuation) + return jsonify({'error': 'Step-up authentication failed'}), 403 def _complete_login(intent, profile, settings): diff --git a/app/jump_host_manager.py b/app/jump_host_manager.py index 63f07e55..cd3cbcfe 100644 --- a/app/jump_host_manager.py +++ b/app/jump_host_manager.py @@ -8,10 +8,22 @@ import uuid import ipaddress from datetime import datetime, timezone + +import config + from .audit_logger import log_error, log_info +from .connection_storage_policy import ( + ConnectionStorageLimitError, + enforce_store_read_limit, + enforce_store_recovery_limit, + enforce_store_transition, + recovery_record_selector, + resolve_recovery_record_selector, + validate_jump_host, +) from .storage_errors import StorageCorruptionError from .storage_utils import ( - atomic_write_json, + atomic_write_bytes, load_json_migrated, safe_reference_name, storage_lock, @@ -19,6 +31,9 @@ from .storage_migrations import CURRENT_STORAGE_VERSIONS +_JUMP_HOST_USAGE_DETAIL_LIMIT = 20 + + def _is_valid_host(host): host = (host or '').strip() if not host: @@ -70,6 +85,22 @@ def _valid_jump_host_document(value): ) +def _jump_host_migration_payload(path, document): + """Return a quota-safe exact migration payload, or keep it in memory.""" + jump_hosts = document['jump_hosts'] + try: + return enforce_store_transition( + path=path, + other_path=path.parent / 'profiles.json', + prospective_document=document, + prospective_count=len(jump_hosts), + previous_count=None, + maximum_count=config.JUMP_HOST_MAX_RECORDS, + ) + except ConnectionStorageLimitError: + return None + + def _load_jump_hosts_with_lock_held(user_id): path = _get_file(user_id) if path is None: @@ -79,17 +110,46 @@ def _load_jump_hosts_with_lock_held(user_id): 'jump_hosts', lambda: {'jump_hosts': []}, _valid_jump_host_document, + migration_payload_factory=lambda document: ( + _jump_host_migration_payload(path, document) + ), ) return data['jump_hosts'] +def _load_jump_hosts_for_read_with_lock_held(user_id): + """Load only a response-safe jump-host store while its lock is held.""" + path = _get_file(user_id) + if path is None: + return [] + enforce_store_read_limit(path) + jump_hosts = _load_jump_hosts_with_lock_held(user_id) + enforce_store_read_limit( + path, + record_count=len(jump_hosts), + maximum_count=config.JUMP_HOST_MAX_RECORDS, + ) + return jump_hosts + + def load_jump_hosts(user_id): """Load all jump hosts for a user.""" - with storage_lock(f'jump_hosts:{user_id}'): - return _load_jump_hosts_with_lock_held(user_id) + # A read can persist a schema migration. Coordinate it with profile and + # jump-host mutations so combined byte accounting cannot observe a stale + # sibling store before the migration replaces this file. + with storage_lock(f'command-config:{user_id}'): + with storage_lock(f'jump_hosts:{user_id}'): + return _load_jump_hosts_for_read_with_lock_held(user_id) -def save_jump_hosts(user_id, jump_hosts): +def save_jump_hosts( + user_id, + jump_hosts, + *, + previous_count=None, + previous_document=None, + compact=False, +): try: f = _get_file(user_id) document = { @@ -98,9 +158,21 @@ def save_jump_hosts(user_id, jump_hosts): } if not f or not _valid_jump_host_document(document): return False + payload = enforce_store_transition( + path=f, + other_path=f.parent / 'profiles.json', + prospective_document=document, + prospective_count=len(jump_hosts), + previous_count=previous_count, + maximum_count=config.JUMP_HOST_MAX_RECORDS, + previous_document=previous_document, + compact=compact, + ) f.parent.mkdir(parents=True, exist_ok=True) - atomic_write_json(f, document) + atomic_write_bytes(f, payload) return True + except ConnectionStorageLimitError: + raise except OSError as e: log_error("Error saving jump hosts", user_id=user_id, error=str(e)) return False @@ -109,10 +181,88 @@ def save_jump_hosts(user_id, jump_hosts): def _load_profile_references(user_id): from . import profile_manager - path = profile_manager.get_user_profiles_file(user_id) + return profile_manager._load_profiles_for_read_with_lock_held(user_id) + + +def _load_jump_hosts_for_recovery_delete(user_id): + """Load legacy jump hosts within the hard recovery ceiling.""" + path = _get_file(user_id) if path is None: return [] - return profile_manager._load_profiles_with_lock_held(user_id) + enforce_store_recovery_limit(path) + data = load_json_migrated( + path, + 'jump_hosts', + lambda: {'jump_hosts': []}, + _valid_jump_host_document, + persist_migration=False, + pre_migration_check=lambda document: enforce_store_recovery_limit( + path, + record_count=( + len(document['jump_hosts']) + if isinstance(document, dict) + and isinstance(document.get('jump_hosts'), list) + else None + ), + ), + ) + jump_hosts = data['jump_hosts'] + enforce_store_recovery_limit( + path, + record_count=len(jump_hosts), + ) + return jump_hosts + + +def load_jump_host_recovery_summaries(user_id): + """Return bounded, non-secret selectors for offline legacy recovery.""" + with storage_lock(f'jump_hosts:{user_id}'): + jump_hosts = _load_jump_hosts_for_recovery_delete(user_id) + scope = f'jump-hosts:{user_id}' + return [ + { + 'selector': recovery_record_selector( + scope, + index, + jump_host, + ), + 'id': safe_reference_name(jump_host.get('id')), + 'name': safe_reference_name(jump_host.get('name')), + 'host': safe_reference_name(jump_host.get('host')), + } + for index, jump_host in enumerate(jump_hosts) + if isinstance(jump_host, dict) + ] + + +def _profile_usage_summary(profiles, jump_host_id): + usage_count = 0 + usages = [] + for profile in profiles: + if ( + not isinstance(profile, dict) + or profile.get('jump_host_id') != jump_host_id + ): + continue + usage_count += 1 + if len(usages) < _JUMP_HOST_USAGE_DETAIL_LIMIT: + usages.append(safe_reference_name(profile.get('name'))) + return usage_count, usages + + +def _jump_host_in_use_result(profiles, jump_host_id): + usage_count, usages = _profile_usage_summary(profiles, jump_host_id) + if not usage_count: + return None + noun = 'profile' if usage_count == 1 else 'profiles' + details = '' + if usage_count > len(usages): + details = f' (showing first {len(usages)})' + return ( + False, + f'Jump host is used by {usage_count} {noun}{details}', + usages, + ) def _get_jump_host_with_coordinator_held(user_id, jump_host_id): @@ -120,7 +270,7 @@ def _get_jump_host_with_coordinator_held(user_id, jump_host_id): if not isinstance(jump_host_id, str) or not jump_host_id: return None with storage_lock(f'jump_hosts:{user_id}'): - jump_hosts = _load_jump_hosts_with_lock_held(user_id) + jump_hosts = _load_jump_hosts_for_read_with_lock_held(user_id) for jump_host in jump_hosts: if jump_host.get('id') == jump_host_id: return dict(jump_host) @@ -140,6 +290,8 @@ def add_jump_host(user_id, name, host, port, username, auth_type, key_id=None): try: if not all([name, host, username, auth_type]): return None, "Missing required fields" + if not isinstance(name, str) or not name.strip(): + return None, "Invalid jump host name" host = str(host).strip() if not _is_valid_host(host): @@ -160,28 +312,46 @@ def add_jump_host(user_id, name, host, port, username, auth_type, key_id=None): return None, "Invalid auth_type" if auth_type == 'key' and not key_id: return None, "key_id required for key authentication" - - jump_host = { - 'id': str(uuid.uuid4()), - 'name': str(name)[:128], - 'host': host, - 'port': port, - 'username': username, - 'auth_type': auth_type, - 'key_id': key_id if auth_type == 'key' else None, - 'created_at': datetime.now(timezone.utc).replace( - tzinfo=None - ).isoformat() - } - with storage_lock(f'jump_hosts:{user_id}'): - jump_hosts = _load_jump_hosts_with_lock_held(user_id) - jump_hosts.append(jump_host) - if save_jump_hosts(user_id, jump_hosts): - log_info("Jump host saved", user_id=user_id, name=name) - return jump_host, None - return None, "Failed to save jump host" + if key_id is not None and not isinstance(key_id, str): + return None, "Invalid key reference" + # One coordinator protects cross-store byte accounting and keeps key + # deletion from racing between reference validation and persistence. + with storage_lock(f'command-config:{user_id}'): + if auth_type == 'key': + from .key_manager import get_key + + if get_key(user_id, key_id) is None: + return None, "SSH key not found" + + jump_host = { + 'id': str(uuid.uuid4()), + 'name': name.strip()[:128], + 'host': host, + 'port': port, + 'username': username, + 'auth_type': auth_type, + 'key_id': key_id if auth_type == 'key' else None, + 'created_at': datetime.now(timezone.utc).replace( + tzinfo=None + ).isoformat() + } + validate_jump_host(jump_host) + with storage_lock(f'jump_hosts:{user_id}'): + jump_hosts = _load_jump_hosts_for_read_with_lock_held(user_id) + previous_count = len(jump_hosts) + jump_hosts.append(jump_host) + if save_jump_hosts( + user_id, + jump_hosts, + previous_count=previous_count, + ): + log_info("Jump host saved", user_id=user_id, name=name) + return jump_host, None + return None, "Failed to save jump host" except StorageCorruptionError: raise + except ConnectionStorageLimitError as exc: + return None, str(exc) except Exception as e: return None, str(e) @@ -191,35 +361,101 @@ def delete_jump_host(user_id, jump_host_id): with storage_lock(f'command-config:{user_id}'): with storage_lock(f'profiles:{user_id}'): profiles = _load_profile_references(user_id) - usages = [ - safe_reference_name(profile.get('name')) - for profile in profiles - if ( - isinstance(profile, dict) - and profile.get('jump_host_id') == jump_host_id - ) - ] - if usages: - noun = 'profile' if len(usages) == 1 else 'profiles' - return ( - False, - f'Jump host is used by {len(usages)} {noun}', - usages, - ) + in_use = _jump_host_in_use_result(profiles, jump_host_id) + if in_use is not None: + return in_use with storage_lock(f'jump_hosts:{user_id}'): - jump_hosts = _load_jump_hosts_with_lock_held(user_id) - new_list = [ - jump_host for jump_host in jump_hosts - if jump_host.get('id') != jump_host_id - ] - if len(new_list) == len(jump_hosts): + jump_hosts = _load_jump_hosts_for_read_with_lock_held(user_id) + index = next( + ( + index + for index, jump_host in enumerate(jump_hosts) + if jump_host.get('id') == jump_host_id + ), + None, + ) + if index is None: return False, 'Jump host not found', [] - if save_jump_hosts(user_id, new_list): + new_list = list(jump_hosts) + new_list.pop(index) + previous_document = { + 'schema_version': CURRENT_STORAGE_VERSIONS['jump_hosts'], + 'jump_hosts': jump_hosts, + } + if save_jump_hosts( + user_id, + new_list, + previous_count=len(jump_hosts), + previous_document=previous_document, + ): return True, None, [] return False, 'Failed to delete jump host', [] except StorageCorruptionError: raise + except ConnectionStorageLimitError as exc: + return False, str(exc), [] except Exception as e: log_error("Error deleting jump host", user_id=user_id, error=str(e)) return False, 'Failed to delete jump host', [] + + +def delete_jump_host_recovery_record(user_id, selector): + """Delete one selector-bound jump host from an offline recovery store.""" + try: + with storage_lock(f'command-config:{user_id}'): + from . import profile_manager + + with storage_lock(f'profiles:{user_id}'): + profiles, error = ( + profile_manager._load_profiles_for_recovery_delete(user_id) + ) + if error: + return False, error, [] + with storage_lock(f'jump_hosts:{user_id}'): + jump_hosts = _load_jump_hosts_for_recovery_delete(user_id) + index = resolve_recovery_record_selector( + f'jump-hosts:{user_id}', + jump_hosts, + selector, + ) + if index is None: + return ( + False, + 'Recovery selector not found; list the store again.', + [], + ) + jump_host_id = jump_hosts[index].get('id') + in_use = _jump_host_in_use_result( + profiles, + jump_host_id, + ) + if in_use is not None: + return in_use + + remaining = list(jump_hosts) + remaining.pop(index) + previous_document = { + 'schema_version': CURRENT_STORAGE_VERSIONS['jump_hosts'], + 'jump_hosts': jump_hosts, + } + if save_jump_hosts( + user_id, + remaining, + previous_count=len(jump_hosts), + previous_document=previous_document, + compact=True, + ): + return True, None, [] + return False, 'Failed to delete jump host', [] + except StorageCorruptionError: + raise + except ConnectionStorageLimitError as exc: + return False, str(exc), [] + except Exception as exc: + log_error( + 'Error deleting recovery jump host', + user_id=user_id, + error=str(exc), + ) + return False, 'Failed to delete jump host', [] diff --git a/app/ldap_routes.py b/app/ldap_routes.py index 9c910acf..75c93d4f 100644 --- a/app/ldap_routes.py +++ b/app/ldap_routes.py @@ -7,7 +7,8 @@ from datetime import datetime, timezone from urllib.parse import urlsplit -from flask import Blueprint, jsonify, redirect, render_template, request, session, url_for +from flask import (Blueprint, current_app, jsonify, redirect, render_template, + request, session, url_for) from flask_login import current_user, login_required from sqlalchemy.exc import IntegrityError from werkzeug.exceptions import RequestEntityTooLarge @@ -194,6 +195,11 @@ def ldap_login(): and (mapping.user.is_locked or mapping.user.is_admin) ): raise LDAPLookupRejected('Identity is not linked to an active user') + if mapping is not None: + from .ldap_session import ldap_revocation_pending + + if ldap_revocation_pending(current_app, mapping.user_id): + raise LDAPUnavailable('LDAP access invalidation is pending') if mapping is None and not config.LDAP_AUTO_PROVISION: raise LDAPLookupRejected('Identity is not linked to an active user') if not directory.verify_password( diff --git a/app/ldap_session.py b/app/ldap_session.py index 89de2cdd..8e328651 100644 --- a/app/ldap_session.py +++ b/app/ldap_session.py @@ -1,11 +1,357 @@ """Periodic validation for authenticated LDAP-managed sessions.""" import logging +import time +from dataclasses import dataclass +from pathlib import Path +from threading import RLock from . import user_lifecycle from .audit_logger import log_security_event from .ldap_service import LDAPDirectory, LDAPLookupRejected, LDAPUnavailable from .models import LDAPIdentity, User, db +from .backup_coordination import persistent_write +from .storage_utils import atomic_write_bytes, fsync_parent_directory + + +@dataclass(frozen=True) +class LDAPValidationReceipt: + """One process-local, identity-bound successful LDAP validation.""" + + verified_at_epoch: int + completed_monotonic: float + identity_key: tuple + + +class LDAPValidationInProgress(RuntimeError): + """A foreground request must retry instead of waiting for LDAP.""" + + +class LDAPRevocationFence: + """Fail closed across threads and restarts while revocation is pending.""" + + def __init__(self, marker_directory, *, clock=None, epoch_clock=None): + self._lock = RLock() + self._pending = set() + self._validating = {} + self._validation_locks = {} + self._successful_validations = {} + self._marker_directory = Path(marker_directory) + self._clock = clock or time.monotonic + self._epoch_clock = epoch_clock or time.time + + def _marker_path(self, user_id): + return self._marker_directory / str(int(user_id)) + + def _marker_exists_locked(self, user_id): + try: + self._marker_path(user_id).lstat() + except FileNotFoundError: + return False + except OSError: + # An unreadable durable fence must deny access, not silently turn + # into an empty in-memory state after a restart. + return True + return True + + def _write_marker_locked(self, user_id): + self._marker_directory.mkdir( + parents=True, + exist_ok=True, + mode=0o700, + ) + atomic_write_bytes( + self._marker_path(user_id), + b'pending\n', + mode=0o600, + ) + + def validation_lock(self, user_id): + user_id = int(user_id) + with self._lock: + return self._validation_locks.setdefault(user_id, RLock()) + + def monotonic_now(self): + return float(self._clock()) + + def recent_validation( + self, + user_id, + identity_key, + *, + not_before_monotonic, + ): + """Return an eligible trusted success without sliding its timestamp.""" + user_id = int(user_id) + identity_key = tuple(identity_key) + with self._lock: + if user_id in self._pending: + return None + if ( + user_id not in self._validating + and self._marker_exists_locked(user_id) + ): + return None + receipt = self._successful_validations.get(user_id) + if receipt is None: + return None + if receipt.identity_key != identity_key: + self._successful_validations.pop(user_id, None) + return None + if not ( + receipt.completed_monotonic + > float(not_before_monotonic) + ): + return None + return receipt + + def begin_validation(self, user_id): + """Persist uncertainty before consulting the external directory.""" + user_id = int(user_id) + with self._lock: + if ( + user_id in self._pending + or self._marker_exists_locked(user_id) + ): + self._pending.add(user_id) + self._successful_validations.pop(user_id, None) + raise LDAPLookupRejected('LDAP revocation is pending') + # Other threads in this process may continue using the last known + # valid result while the lookup runs. If this process dies, the + # marker is discovered by the replacement process and fails closed. + token = object() + self._validating[user_id] = token + try: + self._write_marker_locked(user_id) + except BaseException: + self._validating.pop(user_id, None) + self._pending.add(user_id) + self._successful_validations.pop(user_id, None) + raise + return token + + def fail_validation(self, user_id, token): + user_id = int(user_id) + with self._lock: + if self._validating.get(user_id) is not token: + return + self._validating.pop(user_id, None) + self._pending.add(user_id) + self._successful_validations.pop(user_id, None) + + def _discard_marker_locked(self, user_id): + marker = self._marker_path(user_id) + removed = False + with persistent_write(): + try: + marker.unlink() + removed = True + except FileNotFoundError: + pass + if removed: + fsync_parent_directory(marker) + + def complete_validation(self, user_id, token, identity_key): + """Publish success only if no newer invalidation replaced this run.""" + user_id = int(user_id) + identity_key = tuple(identity_key) + with self._lock: + if ( + self._validating.get(user_id) is not token + or user_id in self._pending + ): + raise LDAPLookupRejected('LDAP revocation is pending') + self._discard_marker_locked(user_id) + self._validating.pop(user_id, None) + self._pending.discard(user_id) + receipt = LDAPValidationReceipt( + verified_at_epoch=int(self._epoch_clock()), + completed_monotonic=float(self._clock()), + identity_key=identity_key, + ) + self._successful_validations[user_id] = receipt + return receipt + + def mark(self, user_id): + user_id = int(user_id) + with self._lock: + self._validating.pop(user_id, None) + self._pending.add(user_id) + self._successful_validations.pop(user_id, None) + self._write_marker_locked(user_id) + + def discard(self, user_id): + user_id = int(user_id) + with self._lock: + self._discard_marker_locked(user_id) + self._pending.discard(user_id) + self._validating.pop(user_id, None) + self._successful_validations.pop(user_id, None) + + def contains(self, user_id): + user_id = int(user_id) + with self._lock: + if user_id in self._pending: + return True + if user_id in self._validating: + return False + return self._marker_exists_locked(user_id) + + +def _revocation_fence(app): + fence = app.extensions.get('ldap_revocation_fence') + if not isinstance(fence, LDAPRevocationFence): + raise RuntimeError('LDAP revocation fence is unavailable') + return fence + + +def ldap_revocation_pending(app, user_id): + return _revocation_fence(app).contains(user_id) + + +def _validation_identity_key(user): + mapping = user.ldap_identity + if mapping is None or user.is_locked or user.is_admin: + raise LDAPLookupRejected('LDAP account is not eligible') + return ( + int(user.auth_generation or 0), + int(mapping.id), + str(mapping.provider), + str(mapping.subject), + str(mapping.directory_username), + ) + + +def _perform_durable_validation(fence, user, identity_key): + token = None + try: + token = fence.begin_validation(user.id) + except LDAPLookupRejected: + raise + except BaseException as error: + raise LDAPUnavailable('LDAP revocation fence unavailable') from error + try: + revalidate_user(user) + except BaseException: + fence.fail_validation(user.id, token) + raise + try: + return fence.complete_validation(user.id, token, identity_key) + except LDAPLookupRejected: + fence.fail_validation(user.id, token) + raise + except BaseException as error: + fence.fail_validation(user.id, token) + raise LDAPUnavailable('LDAP revocation fence unavailable') from error + + +def ensure_recent_ldap_validation(app, user, *, max_age_seconds): + """Reuse or elect one nonblocking foreground LDAP validation.""" + fence = _revocation_fence(app) + max_age_seconds = float(max_age_seconds) + if max_age_seconds <= 0: + raise ValueError('LDAP validation maximum age must be positive') + identity_key = _validation_identity_key(user) + not_before = fence.monotonic_now() - max_age_seconds + receipt = fence.recent_validation( + user.id, + identity_key, + not_before_monotonic=not_before, + ) + if receipt is not None: + return receipt + + validation_lock = fence.validation_lock(user.id) + if not validation_lock.acquire(blocking=False): + receipt = fence.recent_validation( + user.id, + identity_key, + not_before_monotonic=( + fence.monotonic_now() - max_age_seconds + ), + ) + if receipt is not None: + return receipt + raise LDAPValidationInProgress('LDAP validation is in progress') + try: + identity_key = _validation_identity_key(user) + receipt = fence.recent_validation( + user.id, + identity_key, + not_before_monotonic=( + fence.monotonic_now() - max_age_seconds + ), + ) + if receipt is not None: + return receipt + return _perform_durable_validation(fence, user, identity_key) + finally: + validation_lock.release() + + +def revalidate_user_durably( + app, + user, + *, + not_before_monotonic=None, +): + """Revalidate LDAP while a crash-recoverable uncertainty marker exists.""" + fence = _revocation_fence(app) + with fence.validation_lock(user.id): + identity_key = _validation_identity_key(user) + if not_before_monotonic is not None: + receipt = fence.recent_validation( + user.id, + identity_key, + not_before_monotonic=not_before_monotonic, + ) + if receipt is not None: + return receipt + return _perform_durable_validation(fence, user, identity_key) + + +def persist_ldap_authentication_invalidation(app, user): + """Invalidate old credentials or retain a durable deny fence.""" + fence = _revocation_fence(app) + marker_error = None + marker_control_flow = None + try: + fence.mark(user.id) + except BaseException as error: + # The database generation/session boundary is an independent durable + # fallback. A marker I/O failure must not skip that invalidation. + marker_error = error + if not isinstance(error, Exception): + marker_control_flow = error + try: + from .auth_assurance import invalidate_user_authentication + + invalidate_user_authentication(user) + db.session.commit() + except Exception as database_error: + db.session.rollback() + if marker_control_flow is not None: + raise marker_control_flow from database_error + return database_error + except BaseException: + db.session.rollback() + raise + try: + fence.discard(user.id) + except Exception as error: + if marker_control_flow is not None: + raise marker_control_flow from error + return error + if marker_error is not None and marker_control_flow is None: + log_security_event( + 'LDAP_REVOCATION_MARKER_WRITE_FAILED', + level=logging.ERROR, + user=user.username, + error=type(marker_error).__name__, + ) + if marker_control_flow is not None: + raise marker_control_flow + return None def revalidate_user(user): @@ -21,6 +367,7 @@ def revalidate_user(user): def revalidate_all_linked_users(app, socketio_instance=None): """Revoke live access for mappings that no longer validate.""" with app.app_context(): + sweep_started = _revocation_fence(app).monotonic_now() user_ids = [ user_id for (user_id,) in ( @@ -33,16 +380,49 @@ def revalidate_all_linked_users(app, socketio_instance=None): user = db.session.get(User, user_id) if user is None: continue + if ldap_revocation_pending(app, user_id): + invalidation_error = persist_ldap_authentication_invalidation( + app, + user, + ) + if invalidation_error is not None: + log_security_event( + 'LDAP_BACKGROUND_AUTHENTICATION_INVALIDATION_FAILED', + level=logging.ERROR, + user=user.username, + error=type(invalidation_error).__name__, + ) + user_lifecycle.revoke_user_access( + user_id, + socketio_instance, + ) + continue try: - revalidate_user(user) + revalidate_user_durably( + app, + user, + not_before_monotonic=sweep_started, + ) except (LDAPLookupRejected, LDAPUnavailable) as exc: + username = user.username log_security_event( 'LDAP_BACKGROUND_REVALIDATION_REJECTED', level=logging.WARNING, - user=user.username, + user=username, error=type(exc).__name__, ) + invalidation_error = persist_ldap_authentication_invalidation( + app, + user, + ) + if invalidation_error is not None: + log_security_event( + 'LDAP_BACKGROUND_AUTHENTICATION_INVALIDATION_FAILED', + level=logging.ERROR, + user=username, + error=type(invalidation_error).__name__, + ) user_lifecycle.revoke_user_access( - user.id, + user_id, socketio_instance, ) diff --git a/app/maintenance_mode.py b/app/maintenance_mode.py index 943ce476..4c083a2b 100644 --- a/app/maintenance_mode.py +++ b/app/maintenance_mode.py @@ -17,6 +17,55 @@ _state = None _state_path = None _STATUS_NAME = 'restore-status.json' +_KNOWN_STATES = { + 'preparing', 'in_progress', 'succeeded', 'failed', 'rollback_failed', +} + + +def _unreadable_status(): + return { + 'state': 'rollback_failed', + 'message': 'Restore status is unreadable', + 'updated_at': time.time(), + } + + +def _valid_status_document(document) -> bool: + """Reject every malformed persisted state instead of failing open.""" + if not isinstance(document, dict): + return False + state = document.get('state') + if state not in _KNOWN_STATES: + return False + if ( + not isinstance(document.get('operation_id'), str) + or not document['operation_id'] + or len(document['operation_id']) > 256 + or not isinstance(document.get('message'), str) + or type(document.get('updated_at')) not in {int, float} + ): + return False + fingerprint = document.get('data_fingerprint') + if fingerprint is not None and ( + not isinstance(fingerprint, str) + or len(fingerprint) != 64 + or any(character not in '0123456789abcdef' for character in fingerprint) + ): + return False + rollback_relative = document.get('rollback_relative') + if rollback_relative is not None: + if not isinstance(rollback_relative, str): + return False + path = PurePath(rollback_relative) + if path.is_absolute() or '..' in path.parts or len(path.parts) != 2: + return False + if state == 'in_progress' and ( + fingerprint is None or rollback_relative is None + ): + return False + if state == 'preparing' and fingerprint is None: + return False + return True def _status_path() -> Path: @@ -67,13 +116,9 @@ def _read(): except FileNotFoundError: return None except (OSError, UnicodeError, json.JSONDecodeError): - return { - 'state': 'rollback_failed', - 'message': 'Restore status is unreadable', - 'updated_at': time.time(), - } - if not isinstance(document, dict): - return None + return _unreadable_status() + if not _valid_status_document(document): + return _unreadable_status() _state = document _state_path = path return dict(document) @@ -199,7 +244,11 @@ def recover_interrupted_restore() -> None: return operation_id = str(document.get('operation_id') or 'unknown') if document.get('data_fingerprint') != _data_fingerprint(): - mark_failed(operation_id, 'Interrupted restore belongs to another data directory') + mark_failed( + operation_id, + 'Interrupted restore belongs to another data directory', + rollback_failed=True, + ) return if document.get('state') == 'preparing': mark_failed(operation_id, 'Restore stopped before persistent state changed') diff --git a/app/models.py b/app/models.py index 08efd1fa..2b480613 100644 --- a/app/models.py +++ b/app/models.py @@ -89,6 +89,12 @@ class User(db.Model, UserMixin): cascade='all, delete-orphan', lazy='dynamic', ) + factor_bootstrap_tokens = db.relationship( + 'FactorBootstrapToken', + backref='user', + cascade='all, delete-orphan', + lazy='dynamic', + ) def set_password(self, password): """Hash and set user password using bcrypt.""" @@ -534,6 +540,32 @@ class TOTPEnrollment(db.Model): expires_at = db.Column(db.DateTime, nullable=False, index=True) +class FactorBootstrapToken(db.Model): + """One-use local-operator authorization for an initial durable factor.""" + + __tablename__ = 'factor_bootstrap_tokens' + + id = db.Column(db.Integer, primary_key=True) + token_hash = db.Column( + db.String(64), unique=True, nullable=False, index=True + ) + user_id = db.Column( + db.Integer, + db.ForeignKey('users.id'), + nullable=False, + index=True, + ) + auth_generation = db.Column(db.Integer, nullable=False) + action = db.Column(db.String(96), nullable=False, index=True) + created_at = db.Column( + db.DateTime, + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + expires_at = db.Column(db.DateTime, nullable=False, index=True) + consumed_at = db.Column(db.DateTime) + + class StepUpGrant(db.Model): """Single-use authorization for one action and target.""" @@ -612,6 +644,7 @@ def cleanup_expired_security_rows(limit=500, now=None): for model in ( StepUpIntent, StepUpGrant, + FactorBootstrapToken, GitHubOAuthState, PendingAuthentication, TOTPEnrollment, diff --git a/app/network_policy.py b/app/network_policy.py index 375bb95e..f26e740c 100644 --- a/app/network_policy.py +++ b/app/network_policy.py @@ -73,7 +73,13 @@ def _ip_is_internal(address): ) -def resolve_allowed_target(hostname, port, allow_internal=False): +def resolve_allowed_target( + hostname, + port, + allow_internal=False, + *, + target_validator=None, +): """Resolve once and select the first policy-allowed TCP address.""" canonical = canonicalize_hostname(hostname) try: @@ -91,12 +97,15 @@ def resolve_allowed_target(hostname, port, allow_internal=False): if literal is not None: if not allow_internal and _ip_is_internal(literal): raise ValueError('Connections to this address are not allowed') - return ResolvedTarget( + target = ResolvedTarget( canonical, clean_port, literal.compressed, socket.AF_INET6 if literal.version == 6 else socket.AF_INET, ) + if target_validator is not None and not target_validator(target): + raise ValueError('Connections to this address are not allowed') + return target try: candidates = socket.getaddrinfo( @@ -128,22 +137,54 @@ def resolve_allowed_target(hostname, port, allow_internal=False): if address.version == 6 and family != socket.AF_INET6: continue if allow_internal or not _ip_is_internal(address): - return ResolvedTarget( + target = ResolvedTarget( canonical, clean_port, address.compressed, family, sockaddr, ) + if target_validator is None or target_validator(target): + return target raise ValueError('Connections to this address are not allowed') -def open_validated_socket(target, timeout): - """Connect a TCP socket to the already resolved address without DNS.""" +_LINUX_IP_UNICAST_IF = 50 +_LINUX_IPV6_UNICAST_IF = 76 + + +def _bind_unicast_interface(connected, family, interface): + """Bind one Linux unicast socket to an interface by index.""" + interface_index = socket.htonl(socket.if_nametoindex(interface)) + if family == socket.AF_INET: + connected.setsockopt( + socket.IPPROTO_IP, + _LINUX_IP_UNICAST_IF, + interface_index, + ) + return + if family == socket.AF_INET6: + connected.setsockopt( + socket.IPPROTO_IPV6, + _LINUX_IPV6_UNICAST_IF, + interface_index, + ) + return + raise ValueError('Connections through this interface are not supported') + + +def open_validated_socket(target, timeout, *, required_interface=None): + """Connect the pinned address, optionally through one exact interface.""" connected = socket.socket(target.family, socket.SOCK_STREAM) try: connected.settimeout(timeout) + if required_interface is not None: + _bind_unicast_interface( + connected, + target.family, + required_interface, + ) connected.connect(target.sockaddr) return connected except Exception: diff --git a/app/paramiko_channels.py b/app/paramiko_channels.py index be838095..2900ca84 100644 --- a/app/paramiko_channels.py +++ b/app/paramiko_channels.py @@ -1,10 +1,36 @@ """Bound Paramiko channel handshakes and long-lived channel operations.""" import socket +import struct import time from threading import Timer import paramiko +from paramiko.sftp import SFTPError + +import config + + +class BoundedSFTPClient(paramiko.SFTPClient): + """Reject attacker-declared SFTP packets before allocating their body.""" + + def _read_packet(self): + header = self._read_all(4) + size = struct.unpack('>I', header)[0] + if size > config.SFTP_MAX_PACKET_BYTES: + try: + self.sock.close() + finally: + raise SFTPError('SFTP packet exceeds configured byte limit') + data = self._read_all(size) + if self.ultra_debug: + self._log( + paramiko.common.DEBUG, + paramiko.util.format_binary(data, 'IN: '), + ) + if size > 0: + return data[0], data[1:] + return 0, bytes() def _request_guard(channel, timeout): @@ -51,7 +77,7 @@ def open_sftp_client(transport, *, timeout, operation_timeout, deadline=None): timeout_guard = _request_guard(channel, handshake_timeout) try: channel.invoke_subsystem('sftp') - sftp = paramiko.SFTPClient(channel) + sftp = BoundedSFTPClient(channel) if channel.closed: raise socket.timeout('SFTP request exceeded its deadline') channel.settimeout(_remaining_timeout(deadline, operation_timeout)) diff --git a/app/post_connect_manager.py b/app/post_connect_manager.py index 8122cbbe..bc7fc2b6 100644 --- a/app/post_connect_manager.py +++ b/app/post_connect_manager.py @@ -2,7 +2,10 @@ from . import command_manager, command_set_manager from .storage_utils import storage_lock -from .startup_commands import normalize_startup_commands +from .startup_commands import ( + normalize_startup_commands, + validate_command_parameters, +) VALID_MODES = {'none', 'free_text', 'command', 'command_set'} @@ -59,10 +62,9 @@ def _resolve_command(command, parameters_override=None, override_present=False): parameters = parameters_override if override_present else command.get('parameters', '') if parameters is None: parameters = command.get('parameters', '') - if not isinstance(parameters, str): - return None, 'Command parameters must be a string' - if '\x00' in parameters: - return None, 'Commands cannot contain NUL bytes' + error = validate_command_parameters(parameters) + if error: + return None, error command_text = command['command'] resolved = command_text + (f' {parameters}' if parameters else '') @@ -116,8 +118,10 @@ def validate_configuration(user_id, payload, dependent_lock_held=False): return None, error override_present = 'parameters_override' in payload override = payload.get('parameters_override') - if override is not None and not isinstance(override, str): - return None, 'Command parameters must be a string' + if override_present and override is not None: + error = validate_command_parameters(override) + if error: + return None, error _resolved, error = _resolve_command( command, override, override_present=override_present ) @@ -176,8 +180,10 @@ def _resolve_configuration_with_coordinator_held(user_id, payload): return None, error override_present = 'parameters_override' in payload override = payload.get('parameters_override') - if override is not None and not isinstance(override, str): - return None, 'Command parameters must be a string' + if override_present and override is not None: + error = validate_command_parameters(override) + if error: + return None, error return _resolve_command( command, override, diff --git a/app/profile_manager.py b/app/profile_manager.py index e5d1cff0..739c5a75 100644 --- a/app/profile_manager.py +++ b/app/profile_manager.py @@ -3,10 +3,26 @@ import uuid from datetime import datetime, timezone +import config + from .audit_logger import log_error +from .connection_storage_policy import ( + ConnectionStorageLimitError, + enforce_store_read_limit, + enforce_store_recovery_limit, + enforce_store_transition, + recovery_record_selector, + resolve_recovery_record_selector, + validate_profile, +) from .post_connect_manager import infer_mode, validate_configuration from .storage_errors import StorageCorruptionError -from .storage_utils import atomic_write_json, load_json_migrated, storage_lock +from .storage_utils import ( + atomic_write_bytes, + load_json_migrated, + safe_reference_name, + storage_lock, +) from .storage_migrations import CURRENT_STORAGE_VERSIONS from .startup_commands import normalize_startup_commands @@ -99,6 +115,8 @@ def _optional_string(item, field, allow_none=False): def _valid_profile(item): if not isinstance(item, dict): return False + if 'tailscale_authorized' in item: + return False if not isinstance(item.get('id'), str) or not isinstance(item.get('name'), str): return False for field in ( @@ -121,7 +139,7 @@ def _valid_profile(item): 'none', 'free_text', 'command', 'command_set', }: return False - for field in ('use_tmux', 'tailscale_authorized', 'favorite'): + for field in ('use_tmux', 'favorite'): if field in item and type(item[field]) is not bool: return False if 'sort_order' in item and not _valid_sort_order(item['sort_order']): @@ -142,11 +160,27 @@ def _valid_profile_document(value): 'id', 'name', 'host', 'port', 'username', 'auth_type', 'key_id', 'jump_host_id', 'startup_mode', 'startup_commands', 'command_id', 'command_set_id', 'parameters_override', 'use_tmux', - 'tailscale_authorized', 'group', 'favorite', 'created_at', 'updated_at', + 'group', 'favorite', 'created_at', 'updated_at', 'sort_order', } +def _profile_migration_payload(profiles_file, document): + """Return a quota-safe exact migration payload, or keep it in memory.""" + profiles = document['profiles'] + try: + return enforce_store_transition( + path=profiles_file, + other_path=profiles_file.parent / 'jump_hosts.json', + prospective_document=document, + prospective_count=len(profiles), + previous_count=None, + maximum_count=config.PROFILE_MAX_RECORDS, + ) + except ConnectionStorageLimitError: + return None + + def _load_profiles_with_lock_held(user_id): profiles_file = get_user_profiles_file(user_id) if profiles_file is None: @@ -156,38 +190,140 @@ def _load_profiles_with_lock_held(user_id): 'profiles', lambda: {'profiles': []}, _valid_profile_document, + migration_payload_factory=lambda document: ( + _profile_migration_payload(profiles_file, document) + ), ) return data['profiles'] +def _load_profiles_for_read_with_lock_held(user_id): + """Load only a response-safe profile store while its lock is held.""" + profiles_file = get_user_profiles_file(user_id) + if profiles_file is None: + return [] + enforce_store_read_limit(profiles_file) + profiles = _load_profiles_with_lock_held(user_id) + enforce_store_read_limit( + profiles_file, + record_count=len(profiles), + maximum_count=config.PROFILE_MAX_RECORDS, + ) + return profiles + + def load_profiles(user_id): """Load all connection profiles for a specific user.""" - with storage_lock(f'profiles:{user_id}'): - return _load_profiles_with_lock_held(user_id) + # A read can persist a schema migration. Hold the same coordinator used + # by normal profile and jump-host mutations so the sibling-store size check + # and the eventual migration write are one cross-store quota transaction. + with storage_lock(f'command-config:{user_id}'): + with storage_lock(f'profiles:{user_id}'): + return _load_profiles_for_read_with_lock_held(user_id) def _load_profiles_for_write(user_id): - """Load profiles without masking corruption before a mutation.""" + """Load only a response-safe profile store before normal mutation.""" profiles_file = get_user_profiles_file(user_id) if not profiles_file: return None, 'User not found' - return _load_profiles_with_lock_held(user_id), None + return _load_profiles_for_read_with_lock_held(user_id), None -def save_profiles(user_id, profiles): - """Save profiles list to JSON file for a specific user.""" + +def _load_profiles_for_recovery_delete(user_id): + """Load an oversized legacy store within the hard recovery ceilings.""" + profiles_file = get_user_profiles_file(user_id) + if not profiles_file: + return None, 'User not found' + enforce_store_recovery_limit(profiles_file) + data = load_json_migrated( + profiles_file, + 'profiles', + lambda: {'profiles': []}, + _valid_profile_document, + persist_migration=False, + pre_migration_check=lambda document: enforce_store_recovery_limit( + profiles_file, + record_count=( + len(document['profiles']) + if isinstance(document, dict) + and isinstance(document.get('profiles'), list) + else None + ), + ), + ) + profiles = data['profiles'] + enforce_store_recovery_limit( + profiles_file, + record_count=len(profiles), + ) + return profiles, None + + +def load_profile_recovery_summaries(user_id): + """Return bounded, non-secret selectors for offline legacy recovery.""" + with storage_lock(f'profiles:{user_id}'): + profiles, error = _load_profiles_for_recovery_delete(user_id) + if error: + return None, error + scope = f'profiles:{user_id}' + return [ + { + 'selector': recovery_record_selector( + scope, + index, + profile, + ), + 'id': safe_reference_name(profile.get('id')), + 'name': safe_reference_name(profile.get('name')), + 'host': safe_reference_name(profile.get('host')), + } + for index, profile in enumerate(profiles) + if isinstance(profile, dict) + ], None + +def save_profiles( + user_id, + profiles, + *, + previous_count=None, + previous_document=None, + compact=False, +): + """Save profiles, compacting only explicitly requested recovery writes.""" try: profiles_file = get_user_profiles_file(user_id) + stored_profiles = [] + for profile in profiles: + stored_profile = dict(profile) if isinstance(profile, dict) else profile + if isinstance(stored_profile, dict): + # This is derived from live launch policy for responses only. + stored_profile.pop('tailscale_authorized', None) + stored_profiles.append(stored_profile) document = { 'schema_version': CURRENT_STORAGE_VERSIONS['profiles'], - 'profiles': profiles, + 'profiles': stored_profiles, } if not profiles_file or not _valid_profile_document(document): return False + payload = enforce_store_transition( + path=profiles_file, + other_path=profiles_file.parent / 'jump_hosts.json', + prospective_document=document, + prospective_count=len(stored_profiles), + previous_count=previous_count, + maximum_count=config.PROFILE_MAX_RECORDS, + previous_document=previous_document, + compact=compact, + ) + profiles_file.parent.mkdir(parents=True, exist_ok=True) - atomic_write_json(profiles_file, document) + atomic_write_bytes(profiles_file, payload) return True + except ConnectionStorageLimitError: + raise except Exception as e: log_error("Error saving profiles", user_id=user_id, error=str(e)) return False @@ -223,6 +359,8 @@ def _validate_profile_payload(user_id, payload, dependent_lock_held=False): return None, 'Invalid username format' if auth_type not in {'password', 'key', 'tailscale'}: return None, 'Invalid auth_type' + if auth_type == 'tailscale' and payload.get('jump_host_id'): + return None, 'Tailscale SSH cannot be used with a jump host' group, error = _normalize_group(payload.get('group', _UNSET)) if error: @@ -237,6 +375,13 @@ def _validate_profile_payload(user_id, payload, dependent_lock_held=False): key_id = payload.get('key_id') if auth_type == 'key' and not key_id: return None, 'key_id required for key authentication' + if key_id is not None and not isinstance(key_id, str): + return None, 'Invalid key reference' + if auth_type == 'key': + from .key_manager import get_key + + if get_key(user_id, key_id) is None: + return None, 'SSH key not found' post_connect, error = validate_configuration( user_id, @@ -315,6 +460,7 @@ def upsert_profile(user_id, payload, preserve_legacy_fallback=False): return None, error profile_id = payload.get('id') + previous_count = len(profiles) now = datetime.now(timezone.utc).isoformat() if profile_id: for index, existing in enumerate(profiles): @@ -361,6 +507,7 @@ def upsert_profile(user_id, payload, preserve_legacy_fallback=False): profiles, target_group, exclude_id=profile_id ) profiles[index] = result + validate_profile(result, existing) break else: return None, 'Profile not found' @@ -375,12 +522,19 @@ def upsert_profile(user_id, payload, preserve_legacy_fallback=False): 'updated_at': now, } profiles.append(result) + validate_profile(result) - if save_profiles(user_id, profiles): + if save_profiles( + user_id, + profiles, + previous_count=previous_count, + ): return result, None return None, 'Failed to save profile' except StorageCorruptionError: raise + except ConnectionStorageLimitError as exc: + return None, str(exc) except Exception as exc: log_error('Error saving profile', user_id=user_id, error=str(exc)) return None, 'Failed to save profile' @@ -441,6 +595,7 @@ def update_profile_organization(user_id, profile_id, patch): for profile in profiles: if profile.get('id') != profile_id: continue + previous = dict(profile) if group is not _UNSET: if group: profile['group'] = group @@ -452,12 +607,19 @@ def update_profile_organization(user_id, profile_id, patch): else: profile.pop('favorite', None) profile['updated_at'] = datetime.now(timezone.utc).isoformat() - if not save_profiles(user_id, profiles): + validate_profile(profile, previous) + if not save_profiles( + user_id, + profiles, + previous_count=len(profiles), + ): return None, 'Failed to save profile' return dict(profile), None return None, 'Profile not found' except StorageCorruptionError: raise + except ConnectionStorageLimitError as exc: + return None, str(exc) except Exception as exc: log_error( 'Error updating profile organization', @@ -501,6 +663,7 @@ def move_profile( ) if profile is None: return None, 'Profile not found' + previous = dict(profile) source_group = profile.get('group') if _group_key(source_group) != _group_key(expected_source_group): @@ -549,7 +712,12 @@ def move_profile( if member.get('id') == profile_id: member['updated_at'] = now - if not save_profiles(user_id, profiles): + validate_profile(profile, previous) + if not save_profiles( + user_id, + profiles, + previous_count=len(profiles), + ): return None, 'Failed to save profile' return { 'profiles': profiles, @@ -557,6 +725,8 @@ def move_profile( }, None except StorageCorruptionError: raise + except ConnectionStorageLimitError as exc: + return None, str(exc) except Exception as exc: log_error( 'Error moving profile', @@ -573,20 +743,85 @@ def delete_profile(user_id, profile_id): profiles, error = _load_profiles_for_write(user_id) if error: return False, error - found = any(profile.get('id') == profile_id for profile in profiles) - if not found: + index = next( + ( + index + for index, profile in enumerate(profiles) + if profile.get('id') == profile_id + ), + None, + ) + if index is None: return False, 'Profile not found' - remaining = [profile for profile in profiles if profile.get('id') != profile_id] - if save_profiles(user_id, remaining): + previous_document = { + 'schema_version': CURRENT_STORAGE_VERSIONS['profiles'], + 'profiles': profiles, + } + remaining = list(profiles) + remaining.pop(index) + if save_profiles( + user_id, + remaining, + previous_count=len(profiles), + previous_document=previous_document, + ): return True, None return False, 'Failed to delete profile' except StorageCorruptionError: raise + except ConnectionStorageLimitError as exc: + return False, str(exc) except Exception as e: log_error("Error deleting profile", user_id=user_id, error=str(e)) return False, 'Failed to delete profile' +def delete_profile_recovery_record(user_id, selector): + """Delete one selector-bound profile from an offline recovery store.""" + try: + with storage_lock(f'command-config:{user_id}'): + with storage_lock(f'profiles:{user_id}'): + profiles, error = _load_profiles_for_recovery_delete(user_id) + if error: + return False, error + index = resolve_recovery_record_selector( + f'profiles:{user_id}', + profiles, + selector, + ) + if index is None: + return ( + False, + 'Recovery selector not found; list the store again.', + ) + previous_document = { + 'schema_version': CURRENT_STORAGE_VERSIONS['profiles'], + 'profiles': profiles, + } + remaining = list(profiles) + remaining.pop(index) + if save_profiles( + user_id, + remaining, + previous_count=len(profiles), + previous_document=previous_document, + compact=True, + ): + return True, None + return False, 'Failed to delete profile' + except StorageCorruptionError: + raise + except ConnectionStorageLimitError as exc: + return False, str(exc) + except Exception as exc: + log_error( + 'Error deleting recovery profile', + user_id=user_id, + error=str(exc), + ) + return False, 'Failed to delete profile' + + def assign_command_set(user_id, profile_id, command_set_id): """Assign an existing command set without removing legacy fallback data.""" try: @@ -608,12 +843,19 @@ def assign_command_set(user_id, profile_id, command_set_id): if profile.get('id') == profile_id: profile['startup_mode'] = 'command_set' profile['command_set_id'] = command_set['id'] - if not save_profiles(user_id, profiles): + validate_profile(profile) + if not save_profiles( + user_id, + profiles, + previous_count=len(profiles), + ): return None, 'Failed to save profile' return profile, None return None, 'Profile not found' except StorageCorruptionError: raise + except ConnectionStorageLimitError as exc: + return None, str(exc) except Exception as e: log_error('Error assigning command set to profile', user_id=user_id, error=str(e)) return None, str(e) diff --git a/app/remote_transfer.py b/app/remote_transfer.py index fefdb731..972fc4ae 100644 --- a/app/remote_transfer.py +++ b/app/remote_transfer.py @@ -12,7 +12,7 @@ from .smb_backend import FileConflict, NonAtomicOverwriteRequired from .smb_protocol import SMBProtocolError -from .file_backend import FileReaderLease +from .file_backend import FileReaderLease, FileSourceChanged class RemoteTransferError(RuntimeError): @@ -168,18 +168,25 @@ def _copy_file( progress, chunk_size, digest, + expected_identities=None, ): - file_stat = _stat_or_raise(source, source_path) - if not file_stat or file_stat.get('is_dir'): - raise RemoteTransferError('Source file unavailable') - if file_stat.get('is_symlink'): - raise RemoteTransferError('Reparse points are not supported') + if expected_identities is None: + file_stat = _stat_or_raise(source, source_path) + if not file_stat or file_stat.get('is_dir'): + raise RemoteTransferError('Source file unavailable') + if file_stat.get('is_symlink'): + raise RemoteTransferError('Reparse points are not supported') _check_cancelled(cancel_event) copied = 0 try: + reader_kwargs = {'io_lane': 'transfer'} + if expected_identities is not None: + reader_kwargs['_expected_identities'] = expected_identities with source.backend.open_reader( - source, source_path, io_lane='transfer' + source, + source_path, + **reader_kwargs, ) as lease: if not isinstance(lease, FileReaderLease): raise RemoteTransferError('Source reader is unavailable') @@ -218,6 +225,7 @@ def _copy_file( RemoteTransferError, RemoteTransferCancelled, FileConflict, + FileSourceChanged, NonAtomicOverwriteRequired, SMBProtocolError, PermissionError, @@ -324,16 +332,23 @@ def _copy_remote_entry_locked( progress=progress, chunk_size=chunk_size, digest=digest, + expected_identities=source_stat.get('_smb_identity_chain'), ) return TransferResult(copied, 1, digest.hexdigest()) + iterator_kwargs = { + 'budget': budget, + 'cancel_event': cancel_event, + 'follow_links': False, + 'io_lane': 'transfer', + } + root_identities = source_stat.get('_smb_identity_chain') + if root_identities is not None: + iterator_kwargs['_expected_identities'] = root_identities entries = list(source.backend.iter_tree( source, source_path, - budget=budget, - cancel_event=cancel_event, - follow_links=False, - io_lane='transfer', + **iterator_kwargs, )) if any(entry.get('is_symlink') for entry in entries): raise RemoteTransferError('Reparse points are not supported') @@ -393,6 +408,7 @@ def _copy_remote_entry_locked( progress=progress, chunk_size=chunk_size, digest=digest, + expected_identities=entry.get('_smb_identity_chain'), ) return TransferResult( budget.bytes_used, diff --git a/app/restore_sanitizer.py b/app/restore_sanitizer.py index 1907c774..b87ba9c6 100644 --- a/app/restore_sanitizer.py +++ b/app/restore_sanitizer.py @@ -5,6 +5,7 @@ _TRANSIENT_TABLES = ( + 'factor_bootstrap_tokens', 'github_oauth_states', 'oidc_login_states', 'step_up_grants', diff --git a/app/restore_service.py b/app/restore_service.py index 4f5f1fb6..477e62d3 100644 --- a/app/restore_service.py +++ b/app/restore_service.py @@ -10,7 +10,7 @@ from . import connection_pool, ssh_manager from .audit_logger import log_security_event -from .backup_coordination import operation_lock +from .backup_coordination import operation_lock, require_durable_recovery_storage from .backup_manager import restore_backup from .backup_operations import backup_operations from .online_backup import create_online_backup @@ -39,11 +39,33 @@ def _disconnect_sockets(socketio): try: participants = tuple(manager.get_participants('/', None)) except Exception: - return + participants = () + from .socket_events import ( + disconnect_engineio_transport, + disconnect_socket_transport, + ) + for participant in participants: sid = participant[0] if isinstance(participant, tuple) else participant try: - server.disconnect(sid, namespace='/') + disconnect_socket_transport(server, sid) + except Exception: + continue + + engineio_server = getattr(server, 'eio', None) + capacity = getattr( + engineio_server, + '_webssh_socket_capacity', + None, + ) + engineio_sids = capacity.sids() if capacity is not None else () + for engineio_sid in engineio_sids: + try: + disconnect_engineio_transport( + server, + engineio_sid, + drain=False, + ) except Exception: continue @@ -149,11 +171,40 @@ def _perform_restore(app, socketio, record, username, source_ip, def start_restore(app, socketio, record, username, source_ip, restart_callback=request_process_restart): + require_durable_recovery_storage() + launched = threading.Event() + + def run_restore(): + launched.set() + _perform_restore( + app, + socketio, + record, + username, + source_ip, + restart_callback, + ) + thread = threading.Thread( - target=_perform_restore, - args=(app, socketio, record, username, source_ip, restart_callback), + target=run_restore, name='webssh-restore', daemon=False, ) - thread.start() + try: + thread.start() + except BaseException as error: + # A non-Exception interruption can land after the OS thread was + # created but before Thread.ident or the target's event is observable. + # That state is inherently ambiguous, so keep the operation restoring + # and never permit a second worker against the same archive. + worker_started = ( + not isinstance(error, Exception) + or launched.is_set() + or getattr(thread, 'ident', None) is not None + ) + try: + error.restore_worker_started = worker_started + except Exception: + pass + raise return thread diff --git a/app/sftp_backend.py b/app/sftp_backend.py index a0ac89d5..f0e5774f 100644 --- a/app/sftp_backend.py +++ b/app/sftp_backend.py @@ -21,6 +21,9 @@ def normalize_path(self, path): def list_directory(self, source, path): return sftp_handler.list_directory(source.handle_id, path) + def open_directory_listing(self, source, path): + return sftp_handler.open_directory_listing(source.handle_id, path) + def stat_or_raise(self, source, path, *, follow_links=False): if follow_links: result, error = sftp_handler.get_file_stat(source.handle_id, path) @@ -50,11 +53,11 @@ def stat(self, source, path, *, follow_links=False): source, path, follow_links=follow_links ), None except sftp_handler.SFTPOperationError as exc: - return None, str(exc) + return None, sftp_handler.public_sftp_error(exc) except FileNotFoundError: return None, 'File not found' except Exception as exc: - return None, str(exc) + return None, sftp_handler.public_sftp_error(exc) def mkdir_or_raise(self, source, path): safe_path = self.normalize_path(path) @@ -102,7 +105,7 @@ def delete( sftp.remove(safe_path) return True, None except Exception as exc: - return False, str(exc) + return False, sftp_handler.public_sftp_error(exc) @contextmanager def open_reader(self, source, path, *, io_lane='control'): @@ -175,7 +178,8 @@ def iter_tree( safe_path = self.normalize_path(path) if safe_path is None: raise sftp_handler.SFTPOperationError('invalid remote path') - member_budget = budget or sftp_handler._TransferMemberBudget( + count_budget = budget + metadata_budget = sftp_handler._TransferMemberBudget( config.MAX_TRANSFER_MEMBERS ) @@ -190,12 +194,17 @@ def walk(directory, depth=0): ) if sftp_handler._is_cancelled(cancel_event): raise sftp_handler.TransferCancelled() - with sftp_handler._directory_entries(sftp, directory) as entries: + with sftp_handler._directory_entries( + sftp, + directory, + member_budget=metadata_budget, + ) as entries: for entry in entries: - member_budget.consume() if sftp_handler._is_cancelled(cancel_event): raise sftp_handler.TransferCancelled() name = entry.filename + if count_budget is not None: + count_budget.consume() if not sftp_handler._is_safe_transfer_entry_name(name): raise sftp_handler.SFTPOperationError( 'unsafe transfer entry name' @@ -226,7 +235,7 @@ def check_exists(self, source, path): try: return self.check_exists_or_raise(source, path), None except Exception as exc: - return None, str(exc) + return None, sftp_handler.public_sftp_error(exc) def check_exists_or_raise(self, source, path): safe_path = self.normalize_path(path) diff --git a/app/sftp_handler.py b/app/sftp_handler.py index df7c5016..85e2b9b7 100644 --- a/app/sftp_handler.py +++ b/app/sftp_handler.py @@ -4,6 +4,7 @@ import stat import posixpath import secrets +import struct import tempfile import time import zipfile @@ -212,6 +213,30 @@ class TransferMemberLimitExceeded(SFTPOperationError): """A recursive SFTP operation exceeded its entry-count limit.""" +class RemoteMetadataLimitExceeded(SFTPOperationError): + """Remote-controlled directory metadata exceeded its byte budget.""" + + +_PUBLIC_SFTP_ERROR = 'Remote file operation failed' +_PUBLIC_SFTP_ERROR_MAX_BYTES = 512 + + +def public_sftp_error(error, fallback=_PUBLIC_SFTP_ERROR): + """Return only small, application-authored SFTP errors to clients.""" + if isinstance(error, SFTPOperationError): + message = str(error) + if len(message) > _PUBLIC_SFTP_ERROR_MAX_BYTES: + return fallback + message = message.strip() + try: + message_size = len(message.encode('utf-8')) + except UnicodeEncodeError: + message_size = _PUBLIC_SFTP_ERROR_MAX_BYTES + 1 + if message and message_size <= _PUBLIC_SFTP_ERROR_MAX_BYTES: + return message + return fallback + + class UploadConflict(SFTPOperationError): """The upload destination exists and replacement was not approved.""" @@ -225,26 +250,219 @@ class AtomicOverwriteUnavailable(SFTPOperationError): class _TransferMemberBudget: - def __init__(self, limit): + def __init__(self, limit, metadata_limit=None): if type(limit) is not int or limit < 1: raise ValueError('transfer member limit must be a positive integer') self.limit = limit self.used = 0 + self.metadata_limit = ( + config.REMOTE_LISTING_MAX_METADATA_BYTES + if metadata_limit is None else metadata_limit + ) + if type(self.metadata_limit) is not int or self.metadata_limit < 1: + raise ValueError('metadata limit must be a positive integer') + self.metadata_used = 0 - def consume(self): + def consume(self, name=None, extra_metadata_bytes=0): self.used += 1 if self.used > self.limit: raise TransferMemberLimitExceeded() + if name is None: + return + if type(extra_metadata_bytes) is not int or extra_metadata_bytes < 0: + raise RemoteMetadataLimitExceeded('Invalid remote metadata size') + name_size = _remote_metadata_text_size(name) + next_size = ( + self.metadata_used + + name_size + + extra_metadata_bytes + + 128 + ) + if next_size > self.metadata_limit: + raise RemoteMetadataLimitExceeded( + 'Directory metadata exceeds configured byte limit' + ) + self.metadata_used = next_size + + def consume_entry(self, entry): + name = getattr(entry, 'filename', None) + extra = getattr(entry, '_webssh_extra_metadata_bytes', None) + if extra is None: + longname = getattr(entry, 'longname', None) + extra = _remote_metadata_text_size(longname) if isinstance( + longname, str + ) else 0 + self.consume(name, extra) + + +def _remote_metadata_text_size(value): + maximum = config.REMOTE_FILENAME_MAX_BYTES + if not isinstance(value, str) or len(value) > maximum: + raise RemoteMetadataLimitExceeded( + 'Remote filename exceeds configured byte limit' + ) + try: + size = len(value.encode('utf-8')) + except UnicodeEncodeError as exc: + raise RemoteMetadataLimitExceeded( + 'Remote filename is not valid UTF-8' + ) from exc + if size > maximum: + raise RemoteMetadataLimitExceeded( + 'Remote filename exceeds configured byte limit' + ) + return size + + +def _message_uint32(message, label): + remainder = message.get_remainder() + if len(remainder) < 4: + raise RemoteMetadataLimitExceeded(f'Malformed remote {label}') + value = struct.unpack_from('>I', remainder)[0] + message.get_bytes(4) + return value + + +def _bounded_message_text(message, label): + remainder = message.get_remainder() + if len(remainder) < 4: + raise RemoteMetadataLimitExceeded(f'Malformed remote {label}') + length = struct.unpack_from('>I', remainder)[0] + if ( + length > config.REMOTE_FILENAME_MAX_BYTES + or 4 + length > len(remainder) + ): + raise RemoteMetadataLimitExceeded( + f'Remote {label} exceeds configured byte limit' + ) + raw = remainder[4:4 + length] + try: + value = raw.decode('utf-8') + except UnicodeDecodeError as exc: + raise RemoteMetadataLimitExceeded( + f'Remote {label} is not valid UTF-8' + ) from exc + message.get_bytes(4 + length) + return value + + +def _bounded_message_binary(message, label, maximum): + remainder = message.get_remainder() + if len(remainder) < 4: + raise RemoteMetadataLimitExceeded(f'Malformed remote {label}') + length = struct.unpack_from('>I', remainder)[0] + if length > maximum or 4 + length > len(remainder): + raise RemoteMetadataLimitExceeded( + f'Remote {label} exceeds configured byte limit' + ) + value = bytes(remainder[4:4 + length]) + message.get_bytes(4 + length) + return value + + +def _bounded_sftp_attributes(message, filename, longname): + """Parse SFTP v3 attrs without Paramiko's unbounded extension loop.""" + remainder = message.get_remainder() + offset = 0 + + def take(format_string, label): + nonlocal offset + size = struct.calcsize(format_string) + if offset + size > len(remainder): + raise RemoteMetadataLimitExceeded(f'Malformed remote {label}') + value = struct.unpack_from(format_string, remainder, offset)[0] + offset += size + return value + + flags = take('>I', 'attributes') + known_flags = ( + SFTPAttributes.FLAG_SIZE + | SFTPAttributes.FLAG_UIDGID + | SFTPAttributes.FLAG_PERMISSIONS + | SFTPAttributes.FLAG_AMTIME + | SFTPAttributes.FLAG_EXTENDED + ) + if flags & ~known_flags: + raise RemoteMetadataLimitExceeded('Unsupported remote attributes') + + attributes = SFTPAttributes() + attributes._flags = flags + extension_bytes = 0 + if flags & SFTPAttributes.FLAG_SIZE: + attributes.st_size = take('>Q', 'file size') + if flags & SFTPAttributes.FLAG_UIDGID: + attributes.st_uid = take('>I', 'file owner') + attributes.st_gid = take('>I', 'file group') + if flags & SFTPAttributes.FLAG_PERMISSIONS: + attributes.st_mode = take('>I', 'file permissions') + if flags & SFTPAttributes.FLAG_AMTIME: + attributes.st_atime = take('>I', 'access time') + attributes.st_mtime = take('>I', 'modification time') + if flags & SFTPAttributes.FLAG_EXTENDED: + extension_count = take('>I', 'attribute extensions') + if extension_count > 16: + raise RemoteMetadataLimitExceeded( + 'Remote attributes contain too many extensions' + ) + extension_limit = min( + config.REMOTE_LISTING_MAX_METADATA_BYTES, + 64 * 1024, + ) + for _index in range(extension_count): + values = [] + for label in ('extension name', 'extension value'): + length = take('>I', label) + if ( + length > config.REMOTE_FILENAME_MAX_BYTES + or offset + length > len(remainder) + ): + raise RemoteMetadataLimitExceeded( + f'Remote {label} exceeds configured byte limit' + ) + extension_bytes += length + if extension_bytes > extension_limit: + raise RemoteMetadataLimitExceeded( + 'Remote attribute extensions exceed byte limit' + ) + values.append(bytes(remainder[offset:offset + length])) + offset += length + attributes.attr[values[0]] = values[1] + + message.get_bytes(offset) + attributes.filename = filename + attributes.longname = longname + attributes._webssh_extra_metadata_bytes = ( + _remote_metadata_text_size(longname) + extension_bytes + ) + return attributes -def _iter_paramiko_directory_entries(sftp, remote_path): +def _iter_paramiko_directory_entries(sftp, remote_path, *, member_budget=None): """Stream one directory and always close its remote SFTP handle.""" + if member_budget is None: + member_budget = _TransferMemberBudget(config.MAX_TRANSFER_MEMBERS) adjusted_path = sftp._adjust_cwd(remote_path) sftp._log(10, f'listdir({adjusted_path!r})') response_type, message = sftp._request(CMD_OPENDIR, adjusted_path) if response_type != CMD_HANDLE: raise SFTPError('Expected handle') - handle = message.get_binary() + try: + handle = _bounded_message_binary( + message, + 'directory handle', + config.SFTP_MAX_HANDLE_BYTES, + ) + if message.get_remainder(): + raise RemoteMetadataLimitExceeded( + 'Directory handle response contains trailing metadata' + ) + except RemoteMetadataLimitExceeded: + # Do not reflect an attacker-sized opaque handle in READDIR or CLOSE. + try: + sftp.close() + except Exception: + pass + raise try: while True: try: @@ -253,14 +471,27 @@ def _iter_paramiko_directory_entries(sftp, remote_path): return if response_type != CMD_NAME: raise SFTPError('Expected name response') - for _index in range(message.get_int()): - filename = message.get_text() - longname = message.get_text() - attributes = SFTPAttributes._from_msg( + entry_count = _message_uint32(message, 'directory entry count') + if entry_count == 0: + raise RemoteMetadataLimitExceeded( + 'Malformed empty directory response' + ) + for _index in range(entry_count): + filename = _bounded_message_text(message, 'filename') + longname = _bounded_message_text(message, 'longname') + attributes = _bounded_sftp_attributes( message, filename, longname ) + # Charge every server-controlled entry before filtering dot + # names. Recursive callers pass one shared budget, so a server + # cannot reset count or metadata limits at each directory. + member_budget.consume_entry(attributes) if filename not in ('.', '..'): yield attributes + if message.get_remainder(): + raise RemoteMetadataLimitExceeded( + 'Directory response contains trailing metadata' + ) finally: try: sftp._request(CMD_CLOSE, handle) @@ -276,22 +507,142 @@ def _iter_paramiko_directory_entries(sftp, remote_path): @contextmanager -def _directory_entries(sftp, remote_path): +def _directory_entries(sftp, remote_path, *, member_budget=None): + source_iterator = None if isinstance(sftp, SFTPClient): - iterator = _iter_paramiko_directory_entries(sftp, remote_path) + iterator = _iter_paramiko_directory_entries( + sftp, + remote_path, + member_budget=member_budget, + ) else: factory = getattr(sftp, 'listdir_iter', None) - iterator = ( + source_iterator = ( factory(remote_path) if callable(factory) else iter(sftp.listdir_attr(remote_path)) ) + if member_budget is None: + iterator = source_iterator + else: + def budgeted_entries(): + for entry in source_iterator: + member_budget.consume_entry(entry) + yield entry + + iterator = budgeted_entries() try: yield iterator finally: close = getattr(iterator, 'close', None) if callable(close): close() + if source_iterator is not None and source_iterator is not iterator: + close = getattr(source_iterator, 'close', None) + if callable(close): + close() + + +class _SFTPDirectoryListing: + """One bounded directory enumeration continued across UI pages.""" + + def __init__(self, session_id, remote_path): + self._session_context = None + self._entries_context = None + self._entries = None + self._lookahead = None + self._closed = False + safe_path = sanitize_path(remote_path) + if safe_path is None: + raise SFTPOperationError('Invalid path: path traversal detected') + try: + self._session_context = sftp_session( + session_id, + io_lane='transfer', + ) + sftp, _source_type = self._session_context.__enter__() + self._entries_context = _directory_entries( + sftp, + safe_path, + member_budget=_TransferMemberBudget( + config.MAX_TRANSFER_MEMBERS + ), + ) + self._entries = self._entries_context.__enter__() + except Exception: + self.close() + raise + + @staticmethod + def _payload(entry): + return { + 'name': entry.filename, + 'size': entry.st_size, + 'mode': entry.st_mode, + 'is_dir': stat.S_ISDIR(entry.st_mode), + 'is_symlink': stat.S_ISLNK(entry.st_mode), + 'modified': entry.st_mtime, + } + + def read_page(self, page_size): + if self._closed: + return None, 'Directory listing expired', False + try: + page = [] + if self._lookahead is not None: + page.append(self._lookahead) + self._lookahead = None + while len(page) < page_size: + page.append(self._payload(next(self._entries))) + try: + self._lookahead = self._payload(next(self._entries)) + except StopIteration: + self.close() + return page, None, False + return page, None, True + except StopIteration: + self.close() + return page, None, False + except TransferMemberLimitExceeded: + self.close() + return None, 'Directory exceeds configured member limit', False + except RemoteMetadataLimitExceeded as error: + self.close() + return None, public_sftp_error(error), False + except SFTPOperationError as error: + self.close() + return None, public_sftp_error(error), False + except Exception as error: + self.close() + return None, public_sftp_error(error), False + + def close(self): + if self._closed: + return + self._closed = True + if self._entries_context is not None: + try: + self._entries_context.__exit__(None, None, None) + except Exception: + pass + self._entries_context = None + self._entries = None + if self._session_context is not None: + try: + self._session_context.__exit__(None, None, None) + except Exception: + pass + self._session_context = None + + +def open_directory_listing(session_id, remote_path='.'): + """Open a dedicated, bounded SFTP enumeration for opaque pagination.""" + try: + return _SFTPDirectoryListing(session_id, remote_path), None + except SFTPOperationError as error: + return None, public_sftp_error(error) + except Exception as error: + return None, public_sftp_error(error) def _is_cancelled(cancel_event): @@ -347,9 +698,10 @@ def inspect_remote_tree(sftp, remote_folder, *, cancel_event, max_bytes, raise TransferCancelled() total = 0 has_symlink = False - with _directory_entries(sftp, remote_folder) as entries: + with _directory_entries( + sftp, remote_folder, member_budget=_member_budget + ) as entries: for entry in entries: - _member_budget.consume() name = entry.filename if ( not isinstance(name, str) @@ -386,24 +738,40 @@ def inspect_remote_tree(sftp, remote_folder, *, cancel_event, max_bytes, return total, has_symlink -def build_fallback_zip_to_disk(sftp, remote_folder, folder_name, *, - cancel_event, max_bytes, chunk_size, - max_members=None, temp_dir=None, progress=None): - """Build a ZIP on disk while bounding every remote read and total input.""" - if temp_dir is not None: - temp_dir = Path(temp_dir) - temp_dir.mkdir(mode=0o700, parents=True, exist_ok=True) - os.chmod(temp_dir, 0o700) +def _create_private_temporary_archive(temp_dir): temporary = tempfile.NamedTemporaryFile( suffix='.zip', delete=False, dir=temp_dir ) archive_path = Path(temporary.name) - temporary.close() - os.chmod(archive_path, 0o600) + try: + temporary.close() + os.chmod(archive_path, 0o600) + except BaseException: + try: + temporary.close() + except BaseException: + pass + try: + archive_path.unlink(missing_ok=True) + except BaseException: + pass + raise + return archive_path + + +def build_fallback_zip_to_disk(sftp, remote_folder, folder_name, *, + cancel_event, max_bytes, chunk_size, + max_members=None, temp_dir=None, progress=None): + """Build a ZIP on disk while bounding every remote read and total input.""" transferred = 0 member_budget = _TransferMemberBudget( config.MAX_TRANSFER_MEMBERS if max_members is None else max_members ) + if temp_dir is not None: + temp_dir = Path(temp_dir) + temp_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + os.chmod(temp_dir, 0o700) + archive_path = _create_private_temporary_archive(temp_dir) def add_directory(archive, remote_path, archive_prefix, depth=0): nonlocal transferred @@ -412,10 +780,11 @@ def add_directory(archive, remote_path, archive_prefix, depth=0): if _is_cancelled(cancel_event): raise TransferCancelled() saw_entry = False - with _directory_entries(sftp, remote_path) as entries: + with _directory_entries( + sftp, remote_path, member_budget=member_budget + ) as entries: for entry in entries: saw_entry = True - member_budget.consume() if _is_cancelled(cancel_event): raise TransferCancelled() name = entry.filename @@ -474,8 +843,11 @@ def add_directory(archive, remote_path, archive_prefix, depth=0): if archive_path.stat().st_size > max_bytes: raise TransferSizeExceeded() return archive_path - except Exception: - archive_path.unlink(missing_ok=True) + except BaseException: + try: + archive_path.unlink(missing_ok=True) + except BaseException: + pass raise def get_sftp_client(session_id): @@ -525,7 +897,7 @@ def get_sftp_client(session_id): return sftp, None except Exception as e: - return None, str(e) + return None, public_sftp_error(e, 'Failed to open SFTP channel') def get_sftp_client_fresh(session_id): """Open an uncached SFTP channel for a session or quick connection.""" @@ -542,7 +914,7 @@ def get_sftp_client_fresh(session_id): return sftp, None except Exception as e: - return None, str(e) + return None, public_sftp_error(e, 'Failed to open SFTP channel') def get_ssh_client(identifier): @@ -563,6 +935,16 @@ def close_sftp_cache(session_id): sftp.close() except Exception: pass + try: + from .file_service import file_service + file_service.discard_directory_snapshots( + source_id=f'sftp-session:{session_id}', + ) + file_service.discard_directory_snapshots( + source_id=f'sftp-quick:{session_id}', + ) + except Exception: + pass _cleanup_sftp_lock(session_id) def sanitize_path(remote_path): @@ -590,8 +972,8 @@ def sanitize_path(remote_path): return normalized -def list_directory(session_id, remote_path='.'): - """List files in remote directory with path validation.""" +def _read_directory_listing(session_id, remote_path='.'): + """Materialize one metadata-bounded directory snapshot.""" try: safe_path = sanitize_path(remote_path) if safe_path is None: @@ -600,13 +982,10 @@ def list_directory(session_id, remote_path='.'): with sftp_session(session_id) as (sftp, source_type): files = [] member_budget = _TransferMemberBudget(config.MAX_TRANSFER_MEMBERS) - with _directory_entries(sftp, safe_path) as entries: - while True: - try: - entry = next(entries) - except StopIteration: - break - member_budget.consume() + with _directory_entries( + sftp, safe_path, member_budget=member_budget + ) as entries: + for entry in entries: is_symlink = stat.S_ISLNK(entry.st_mode) files.append({ 'name': entry.filename, @@ -619,10 +998,17 @@ def list_directory(session_id, remote_path='.'): return files, None except TransferMemberLimitExceeded: return None, 'Directory exceeds configured member limit' + except RemoteMetadataLimitExceeded as e: + return None, public_sftp_error(e) except SFTPOperationError as e: - return None, str(e) + return None, public_sftp_error(e) except Exception as e: - return None, str(e) + return None, public_sftp_error(e) + + +def list_directory(session_id, remote_path='.'): + """Return one bounded snapshot for service-layer pagination.""" + return _read_directory_listing(session_id, remote_path) def probe_sftp_capability(session_id): @@ -712,9 +1098,9 @@ def create_directory(session_id, remote_path): sftp.mkdir(safe_path) return True, None except SFTPOperationError as e: - return False, str(e) + return False, public_sftp_error(e) except Exception as e: - return False, str(e) + return False, public_sftp_error(e) def upload_request_stream( @@ -799,9 +1185,9 @@ def rename_item(session_id, old_path, new_path): sftp.rename(safe_old, safe_new) return True, None except SFTPOperationError as e: - return False, str(e) + return False, public_sftp_error(e) except Exception as e: - return False, str(e) + return False, public_sftp_error(e) def delete_directory_recursive( session_id, @@ -838,14 +1224,15 @@ def _delete_recursive(sftp_client, dir_path, depth=0): raise ValueError("Maximum recursion depth exceeded") _check_cancelled() - with _directory_entries(sftp_client, dir_path) as entries: + with _directory_entries( + sftp_client, dir_path, member_budget=member_budget + ) as entries: while True: _check_cancelled() try: entry = next(entries) except StopIteration: break - member_budget.consume() _check_cancelled() name = entry.filename if not _is_safe_transfer_entry_name(name): @@ -885,11 +1272,11 @@ def _delete_recursive(sftp_client, dir_path, depth=0): except TransferCancelled: return False, 'Operation cancelled' except SFTPOperationError as e: - return False, str(e) + return False, public_sftp_error(e) except FileNotFoundError: return False, "File or directory not found" except Exception as e: - return False, str(e) + return False, public_sftp_error(e) def get_home_directory(session_id): """Get the home directory (current working directory) of the SFTP session.""" @@ -898,9 +1285,9 @@ def get_home_directory(session_id): home_path = sftp.normalize('.') return home_path, None except SFTPOperationError as e: - return None, str(e) + return None, public_sftp_error(e) except Exception as e: - return None, str(e) + return None, public_sftp_error(e) def check_exists(session_id, path): """Check if a file or directory exists on remote server.""" @@ -917,9 +1304,9 @@ def check_exists(session_id, path): except FileNotFoundError: return {'exists': False, 'is_dir': False, 'size': 0}, None except SFTPOperationError as e: - return None, str(e) + return None, public_sftp_error(e) except Exception as e: - return None, str(e) + return None, public_sftp_error(e) def get_file_stat(session_id, path): """Get detailed file/directory statistics.""" @@ -941,11 +1328,11 @@ def get_file_stat(session_id, path): 'permissions': oct(file_stat.st_mode)[-3:] }, None except SFTPOperationError as e: - return None, str(e) + return None, public_sftp_error(e) except FileNotFoundError: return None, "File not found" except Exception as e: - return None, str(e) + return None, public_sftp_error(e) def read_file_preview(session_id, path, max_bytes=512000, offset=0, tail_lines=None): """ @@ -1032,14 +1419,25 @@ def read_file_preview(session_id, path, max_bytes=512000, offset=0, tail_lines=N 'offset': offset }, None + except ValueError as e: + message = str(e) + if message in { + 'max_bytes must be a positive integer', + 'offset must be a non-negative integer', + 'tail_lines must be a positive integer', + 'offset exceeds the supported file size', + 'tail_lines exceeds the configured limit', + }: + return None, message + return None, public_sftp_error(e) except SFTPOperationError as e: - return None, str(e) + return None, public_sftp_error(e) except FileNotFoundError: return None, "File not found" except PermissionError: return None, "Permission denied" except Exception as e: - return None, str(e) + return None, public_sftp_error(e) def read_file_for_edit(session_id, path, max_bytes=None): """ @@ -1109,13 +1507,13 @@ def read_file_for_edit(session_id, path, max_bytes=None): }, None except SFTPOperationError as e: - return None, str(e) + return None, public_sftp_error(e) except FileNotFoundError: return None, "File not found" except PermissionError: return None, "Permission denied" except Exception as e: - return None, str(e) + return None, public_sftp_error(e) def write_file_text( session_id, @@ -1197,9 +1595,9 @@ def write_file_text( revision=hashlib.sha256(data).hexdigest(), ) except SFTPOperationError as e: - return FileWriteOutcome(success=False, error=str(e)) + return FileWriteOutcome(success=False, error=public_sftp_error(e)) except Exception as e: - return FileWriteOutcome(success=False, error=str(e)) + return FileWriteOutcome(success=False, error=public_sftp_error(e)) def get_sftp_client_from_pool(connection_id): """Get SFTP client from temporary connection pool.""" @@ -1229,7 +1627,7 @@ def get_any_sftp_client(identifier): _sftp_cache[identifier] = sftp return sftp, None, 'pool' - return None, f"No active connection found for: {identifier}", None + return None, 'No active connection found', None def _is_safe_transfer_entry_name(name): @@ -1257,7 +1655,9 @@ def remove_directory(path, depth=0): return False try: - with _directory_entries(sftp, path) as entries: + with _directory_entries( + sftp, path, member_budget=member_budget + ) as entries: while True: if _is_cancelled(cancel_event): return False @@ -1265,7 +1665,6 @@ def remove_directory(path, depth=0): entry = next(entries) except StopIteration: break - member_budget.consume() if _is_cancelled(cancel_event): return False name = getattr(entry, 'filename', None) @@ -1332,7 +1731,7 @@ def transfer_server_to_server(source_session_id, source_path, dest_session_id, directory_total = None event_context = dict(event_context or {}) if conflict_policy not in {'error', 'replace'}: - return False, SFTPOperationError('unsupported conflict policy') + return False, 'unsupported conflict policy' try: sftp_source, error = get_sftp_client_fresh(source_session_id) @@ -1455,9 +1854,12 @@ def calculate_directory_total(src_dir, depth=0): raise TransferCancelled() total = 0 - with _directory_entries(sftp_source, src_dir) as entries: + with _directory_entries( + sftp_source, + src_dir, + member_budget=preflight_member_budget, + ) as entries: for entry in entries: - preflight_member_budget.consume() if _is_cancelled(cancel_event): raise TransferCancelled() name = entry.filename @@ -1504,9 +1906,12 @@ def transfer_directory_recursive(src_dir, dst_dir, depth=0): sftp_dest.mkdir(dst_dir) - with _directory_entries(sftp_source, src_dir) as entries: + with _directory_entries( + sftp_source, + src_dir, + member_budget=transfer_member_budget, + ) as entries: for entry in entries: - transfer_member_budget.consume() if _is_cancelled(cancel_event): raise TransferCancelled() name = entry.filename @@ -1608,7 +2013,7 @@ def transfer_directory_recursive(src_dir, dst_dir, depth=0): transfer_id=transfer_id, exception_type=type(e).__name__, ) - return False, e + return False, public_sftp_error(e, 'Server-to-server transfer failed') finally: if sftp_source is not None: try: diff --git a/app/smb_backend.py b/app/smb_backend.py index 38ef3b8d..a3098e55 100644 --- a/app/smb_backend.py +++ b/app/smb_backend.py @@ -10,12 +10,25 @@ import config -from .file_backend import FileReaderLease, FileWriteOutcome +from .file_backend import ( + FileOperationCancelled, + FileReaderLease, + FileSourceChanged, + FileWriteOutcome, +) from .smb_paths import SMBPath, SMBPathRejected from .smb_protocol import SMBProtocolError _REPARSE_POINT = 0x00000400 +_DIRECTORY = 0x00000010 +_READ_ONLY = 0x00000001 +_SOURCE_CHANGED_PROTOCOL_CODES = frozenset({ + 'CONFLICT', + 'IDENTITY_UNAVAILABLE', + 'NOT_FOUND', + 'REPARSE_POINT_REJECTED', +}) class SMBBackendError(Exception): @@ -41,6 +54,75 @@ def consume(self): raise SMBBackendError('Directory exceeds configured member limit') +class _SMBDirectoryListing: + """One SMB scandir iterator resumed under the owned source lock.""" + + def __init__(self, backend, source, path): + self._backend = backend + self._actual = backend._owned_source(source) + self._directory = backend._path(path) + self._iterator = None + self._lookahead = None + self._budget = _MemberBudget(config.MAX_TRANSFER_MEMBERS) + self._closed = False + with self._actual.lock: + self._iterator = self._actual.session.invoke( + 'scandir_verified', + self._directory.to_unc( + self._actual.target_ip, + self._actual.share, + ), + ) + + def _next_payload(self): + if self._iterator is None: + raise StopIteration + entry = next(self._iterator) + self._budget.consume() + return self._backend._directory_payload(self._directory, entry) + + def read_page(self, page_size): + if self._closed: + return None, 'Directory listing expired', False + try: + with self._actual.lock: + page = [] + if self._lookahead is not None: + page.append(self._lookahead) + self._lookahead = None + while len(page) < page_size: + page.append(self._next_payload()) + try: + self._lookahead = self._next_payload() + except StopIteration: + self._close_locked() + return page, None, False + return page, None, True + except StopIteration: + with self._actual.lock: + self._close_locked() + return page, None, False + except Exception as error: + with self._actual.lock: + self._close_locked() + return None, self._backend._public_error(error), False + + def _close_locked(self): + if self._closed: + return + self._closed = True + if self._iterator is not None: + try: + self._iterator.close() + except Exception: + pass + self._iterator = None + + def close(self): + with self._actual.lock: + self._close_locked() + + class SMBBackend: def __init__(self, pool=None): self._bound_pool = pool @@ -118,6 +200,43 @@ def _validate_path_components( if self._is_reparse(file_stat): raise SMBBackendError('Reparse points are not supported') + @staticmethod + def _stable_identity(value): + if isinstance(value, bool): + raise SMBBackendError('SMB object identity is unavailable') + try: + identity = int(value) + except (TypeError, ValueError) as exc: + raise SMBBackendError( + 'SMB object identity is unavailable' + ) from exc + if identity <= 0: + raise SMBBackendError('SMB object identity is unavailable') + return identity + + @contextmanager + def _mutation_guard( + self, + actual, + *paths, + session=None, + ): + """Pin every existing ancestor until a path mutation completes.""" + session = session or actual.session + ancestors = [] + seen = set() + for path in paths: + for index in range(1, len(path.segments)): + ancestor = SMBPath(path.segments[:index]).to_unc( + actual.target_ip, + actual.share, + ) + if ancestor not in seen: + seen.add(ancestor) + ancestors.append(ancestor) + with session.pin_mutation_ancestors(ancestors): + yield + @staticmethod def _is_reparse(file_stat): return bool(getattr(file_stat, 'st_file_attributes', 0) & _REPARSE_POINT) @@ -129,6 +248,57 @@ def _is_directory(file_stat): getattr(file_stat, 'st_mode', 0) ) + def _directory_payload( + self, + directory, + entry, + *, + identity_chain=None, + ): + try: + child = directory.child(entry.name) + except SMBPathRejected as exc: + raise SMBBackendError('Unsafe directory response') from exc + info = getattr(entry, 'smb_info', None) + if info is None: + raise SMBBackendError('File metadata is unavailable') + identity = self._stable_identity(getattr(info, 'file_id', None)) + try: + attributes = int(getattr(info, 'file_attributes')) + size = int(getattr(info, 'end_of_file')) + except (TypeError, ValueError, AttributeError) as exc: + raise SMBBackendError('File metadata is unavailable') from exc + if attributes < 0 or size < 0: + raise SMBBackendError('File metadata is unavailable') + is_reparse = bool(attributes & _REPARSE_POINT) + is_directory = bool(attributes & _DIRECTORY) and not is_reparse + mode = ( + stat_module.S_IFDIR | 0o111 + if attributes & _DIRECTORY + else stat_module.S_IFREG + ) + mode |= 0o444 if attributes & _READ_ONLY else 0o666 + modified = getattr(info, 'last_write_time', 0) + if hasattr(modified, 'timestamp'): + modified = modified.timestamp() + if not isinstance(modified, (int, float)): + modified = 0 + payload = { + 'name': entry.name, + 'path': str(child), + 'size': size, + 'mode': mode, + 'is_dir': is_directory, + 'is_symlink': is_reparse, + 'modified': modified, + } + if identity_chain is not None: + payload['_smb_identity'] = identity + payload['_smb_identity_chain'] = tuple(identity_chain) + ( + identity, + ) + return payload + @staticmethod def _reader_lease(remote_file): """Build metadata from the connected SMB handle's CREATE response.""" @@ -158,19 +328,20 @@ def _reader_lease(remote_file): raise SMBBackendError('File metadata is unavailable') from exc @contextmanager - def _open_reader_locked(self, actual, smb_path, session): + def _open_reader_locked( + self, + actual, + smb_path, + session, + *, + expected_identities=None, + ): """Open and validate one object while the caller owns its I/O lane.""" unc = smb_path.to_unc(actual.target_ip, actual.share) - self._validate_path_components( - actual, - smb_path, - session=session, - ) remote_file = session.invoke( - 'open_file_no_follow', + 'open_file_verified', unc, - mode='rb', - buffering=0, + expected_identities=expected_identities, ) with remote_file: yield self._reader_lease(remote_file) @@ -201,8 +372,12 @@ def _write_all(remote_file, data): @staticmethod def _public_error(exc): + if isinstance(exc, FileOperationCancelled): + return 'Operation cancelled' if isinstance(exc, SMBBackendError): return str(exc) + if isinstance(exc, FileSourceChanged): + return 'File conflict' if isinstance(exc, FileNotFoundError): return 'File or directory not found' if isinstance(exc, PermissionError): @@ -217,6 +392,11 @@ def _public_error(exc): 'SHARE_UNAVAILABLE': 'Share unavailable', 'CONFLICT': 'File conflict', 'TIMEOUT': 'SMB operation timed out', + 'REPARSE_POINT_REJECTED': 'Reparse points are not supported', + 'IDENTITY_UNAVAILABLE': ( + 'SMB object identity is unavailable' + ), + 'MUTATION_GUARD_REQUIRED': 'SMB mutation safety check failed', } if exc.public_code in protocol_errors: return protocol_errors[exc.public_code] @@ -237,49 +417,40 @@ def inspect_directory_access(self, source, path): directory = self._path(path) unc = directory.to_unc(actual.target_ip, actual.share) with actual.lock: - self._validate_path_components(actual, directory) return actual.session.inspect_directory_access(unc) def list_directory(self, source, path): - iterator = None try: actual = self._owned_source(source) directory = self._path(path) - unc = directory.to_unc(actual.target_ip, actual.share) items = [] + iterator = None with actual.lock: - self._validate_path_components(actual, directory) - iterator = actual.session.invoke('scandir_no_follow', unc) - for entry in iterator: - try: - child = directory.child(entry.name) - except SMBPathRejected as exc: - raise SMBBackendError('Unsafe directory response') from exc - if len(items) >= config.MAX_TRANSFER_MEMBERS: - raise SMBBackendError('Directory exceeds configured member limit') - file_stat = entry.stat(follow_symlinks=False) - is_reparse = self._is_reparse(file_stat) or entry.is_symlink() - items.append({ - 'name': entry.name, - 'path': str(child), - 'size': getattr(file_stat, 'st_size', 0) or 0, - 'mode': getattr(file_stat, 'st_mode', 0), - 'is_dir': bool( - entry.is_dir(follow_symlinks=False) - and not is_reparse - ), - 'is_symlink': is_reparse, - 'modified': getattr(file_stat, 'st_mtime', 0), - }) + try: + iterator = actual.session.invoke( + 'scandir_verified', + directory.to_unc(actual.target_ip, actual.share), + ) + for entry in iterator: + if len(items) >= config.MAX_TRANSFER_MEMBERS: + raise SMBBackendError( + 'Directory exceeds configured member limit' + ) + items.append( + self._directory_payload(directory, entry) + ) + finally: + if iterator is not None: + iterator.close() return items, None except Exception as exc: return None, self._public_error(exc) - finally: - if iterator is not None: - try: - iterator.close() - except Exception: - pass + + def open_directory_listing(self, source, path): + try: + return _SMBDirectoryListing(self, source, path), None + except Exception as error: + return None, self._public_error(error) def stat_or_raise(self, source, path, *, follow_links=False): if follow_links: @@ -287,23 +458,47 @@ def stat_or_raise(self, source, path, *, follow_links=False): actual = self._owned_source(source) smb_path = self._path(path) with actual.lock: - self._validate_path_components(actual, smb_path) - file_stat = actual.session.invoke( - 'stat', + info = actual.session.invoke( + 'stat_verified', smb_path.to_unc(actual.target_ip, actual.share), - follow_symlinks=False, ) - if self._is_reparse(file_stat): + try: + attributes = int(info.file_attributes) + size = int(info.end_of_file) + modified = float(info.last_write_time) + except (TypeError, ValueError, AttributeError) as exc: + raise SMBBackendError('File metadata is unavailable') from exc + if attributes < 0 or size < 0: + raise SMBBackendError('File metadata is unavailable') + if attributes & _REPARSE_POINT: raise SMBBackendError('Reparse points are not supported') + try: + identity_chain = tuple( + self._stable_identity(value) + for value in info.identity_chain + ) + except (TypeError, AttributeError) as exc: + raise SMBBackendError( + 'SMB object identity is unavailable' + ) from exc + if len(identity_chain) != len(smb_path.segments): + raise SMBBackendError('SMB object identity is unavailable') + mode = ( + stat_module.S_IFDIR | 0o111 + if attributes & _DIRECTORY + else stat_module.S_IFREG + ) + mode |= 0o444 if attributes & _READ_ONLY else 0o666 return { 'name': smb_path.name, 'path': str(smb_path), - 'size': getattr(file_stat, 'st_size', 0) or 0, - 'mode': getattr(file_stat, 'st_mode', 0), - 'is_dir': self._is_directory(file_stat), + 'size': size, + 'mode': mode, + 'is_dir': bool(attributes & _DIRECTORY), 'is_symlink': False, - 'modified': getattr(file_stat, 'st_mtime', 0), - 'permissions': oct(getattr(file_stat, 'st_mode', 0))[-3:], + 'modified': modified, + 'permissions': oct(mode)[-3:], + '_smb_identity_chain': identity_chain, } def stat(self, source, path, *, follow_links=False): @@ -318,13 +513,14 @@ def mkdir_or_raise(self, source, path): actual = self._owned_source(source) smb_path = self._mutable_path(self._path(path)) with actual.lock: - self._validate_path_components( - actual, smb_path, include_leaf=False - ) - actual.session.invoke( - 'mkdir_no_follow', - smb_path.to_unc(actual.target_ip, actual.share), - ) + with self._mutation_guard(actual, smb_path): + self._validate_path_components( + actual, smb_path, include_leaf=False + ) + actual.session.invoke( + 'mkdir_no_follow', + smb_path.to_unc(actual.target_ip, actual.share), + ) def mkdir(self, source, path): try: @@ -341,17 +537,21 @@ def rename(self, source, old_path, new_path, *, replace=False): old_unc = old_smb_path.to_unc(actual.target_ip, actual.share) new_unc = new_smb_path.to_unc(actual.target_ip, actual.share) with actual.lock: - self._validate_path_components(actual, old_smb_path) - self._validate_path_components( - actual, - new_smb_path, - allow_missing_leaf=True, - ) - actual.session.invoke( - 'replace' if replace else 'rename', - old_unc, - new_unc, - ) + with self._mutation_guard( + actual, old_smb_path, new_smb_path + ): + self._validate_path_components(actual, old_smb_path) + self._validate_path_components( + actual, + new_smb_path, + allow_missing_leaf=True, + ) + actual.session.invoke( + 'rename_verified', + old_unc, + new_unc, + replace=replace, + ) return True, None except Exception as exc: return False, self._public_error(exc) @@ -372,61 +572,116 @@ def delete( smb_path = self._mutable_path(self._path(path)) unc = smb_path.to_unc(actual.target_ip, actual.share) with actual.lock: - self._validate_path_components(actual, smb_path) - file_stat = actual.session.invoke( - 'stat', unc, follow_symlinks=False + root_info = actual.session.invoke('stat_verified', unc) + root_chain = tuple( + self._stable_identity(value) + for value in root_info.identity_chain ) - if self._is_reparse(file_stat): + if len(root_chain) != len(smb_path.segments): + raise SMBBackendError( + 'SMB object identity is unavailable' + ) + try: + root_attributes = int(root_info.file_attributes) + except (TypeError, ValueError, AttributeError) as exc: + raise SMBBackendError( + 'File metadata is unavailable' + ) from exc + if root_attributes & _REPARSE_POINT: raise SMBBackendError('Reparse points are not supported') - if self._is_directory(file_stat): - if not recursive: - actual.session.invoke('rmdir', unc) - else: - actual.session.invoke('remove', unc) + is_directory = bool(root_attributes & _DIRECTORY) + if not is_directory or not recursive: + with self._mutation_guard(actual, smb_path): + actual.session.invoke( + 'delete_verified', + unc, + expected_identities=root_chain, + ) return True, None - if recursive: - entries = list(self.iter_tree( - source, - path, - budget=budget, - cancel_event=cancel_event, - follow_links=False, - )) - if any(entry['is_symlink'] for entry in entries): - raise SMBBackendError('Reparse points are not supported') - files = [entry for entry in entries if not entry['is_dir']] - directories = sorted( - (entry for entry in entries if entry['is_dir']), - key=lambda entry: entry['path'].count('/'), - reverse=True, - ) - with actual.lock: - for entry in (*files, *directories): - if cancel_event is not None and cancel_event.is_set(): - raise SMBBackendError('Operation cancelled') - entry_unc = self._unc(actual, entry['path']) - self._validate_path_components( - actual, self._path(entry['path']) + entries = list(self.iter_tree( + source, + path, + budget=budget, + cancel_event=cancel_event, + follow_links=False, + _expected_identities=root_chain, + )) + if any(entry['is_symlink'] for entry in entries): + raise SMBBackendError('Reparse points are not supported') + files = [entry for entry in entries if not entry['is_dir']] + directories = sorted( + (entry for entry in entries if entry['is_dir']), + key=lambda entry: entry['path'].count('/'), + reverse=True, + ) + with actual.lock: + for entry in (*files, *directories): + if cancel_event is not None and cancel_event.is_set(): + raise FileOperationCancelled('Operation cancelled') + entry_path = self._path(entry['path']) + identity_chain = tuple( + self._stable_identity(value) + for value in entry.get( + '_smb_identity_chain', + (), + ) + ) + if len(identity_chain) != len(entry_path.segments): + raise SMBBackendError( + 'SMB object identity is unavailable' ) + with self._mutation_guard(actual, entry_path): actual.session.invoke( - 'rmdir' if entry['is_dir'] else 'remove', - entry_unc, + 'delete_verified', + self._unc(actual, entry['path']), + expected_identities=identity_chain, ) - self._validate_path_components(actual, smb_path) - actual.session.invoke('rmdir', unc) + if cancel_event is not None and cancel_event.is_set(): + raise FileOperationCancelled('Operation cancelled') + with self._mutation_guard(actual, smb_path): + actual.session.invoke( + 'delete_verified', + unc, + expected_identities=root_chain, + ) return True, None except Exception as exc: return False, self._public_error(exc) @contextmanager - def open_reader(self, source, path, *, io_lane='control'): + def open_reader( + self, + source, + path, + *, + io_lane='control', + _expected_identities=None, + ): actual = self._owned_source(source) session, lane_lock = self._io_lane(actual, io_lane) smb_path = self._path(path) with lane_lock: - with self._open_reader_locked(actual, smb_path, session) as lease: - yield lease + opened = False + try: + with self._open_reader_locked( + actual, + smb_path, + session, + expected_identities=_expected_identities, + ) as lease: + opened = True + yield lease + except SMBProtocolError as exc: + if ( + not opened + and _expected_identities is not None + and exc.public_code in _SOURCE_CHANGED_PROTOCOL_CODES + ): + raise FileSourceChanged( + 'The enumerated remote file changed before opening' + ) from exc + raise @contextmanager def open_atomic_writer( @@ -450,59 +705,88 @@ def open_atomic_writer( temporary_unc = temporary.to_unc(actual.target_ip, actual.share) with lane_lock: remote_file = None + installed = False + cleanup_allowed = True try: - self._validate_path_components( - actual, - destination, - allow_missing_leaf=True, - session=session, - ) - remote_file = session.invoke( - 'open_file_no_follow', - temporary_unc, - mode='xb', - buffering=0, - ) - with remote_file: - yield remote_file - if cancel_event is not None and cancel_event.is_set(): - raise SMBBackendError('Operation cancelled') - try: + with self._mutation_guard( + actual, destination, temporary, session=session + ): self._validate_path_components( actual, destination, allow_missing_leaf=True, session=session, ) - session.invoke( - 'replace' if replace else 'rename', + remote_file = session.invoke( + 'create_file_move_verified', temporary_unc, - destination_unc, ) - except Exception as exc: - if replace: - if isinstance(exc, SMBProtocolError): - if exc.public_code == 'PERMISSION_DENIED': - raise NonAtomicOverwriteRequired( - 'Atomic replacement requires delete permission' - ) from exc - if exc.public_code != 'CONFLICT': + yield remote_file + if cancel_event is not None and cancel_event.is_set(): + raise FileOperationCancelled('Operation cancelled') + try: + self._validate_path_components( + actual, + destination, + allow_missing_leaf=True, + session=session, + ) + cleanup_allowed = False + committed, rename_error = ( + self._rename_open_handle_reconciled( + session, + remote_file, + temporary_unc, + destination_unc, + replace=replace, + ) + ) + if committed is True: + installed = True + else: + cleanup_allowed = committed is False + raise rename_error + except Exception as exc: + if replace: + if isinstance(exc, SMBProtocolError): + if exc.public_code == 'PERMISSION_DENIED': + raise NonAtomicOverwriteRequired( + 'Atomic replacement requires delete permission' + ) from exc + if exc.public_code != 'CONFLICT': + raise + elif not ( + isinstance(exc, OSError) + and exc.errno in {errno.EEXIST, errno.ENOTEMPTY} + ): raise - elif not ( - isinstance(exc, OSError) - and exc.errno in {errno.EEXIST, errno.ENOTEMPTY} - ): - raise - raise FileConflict( - 'Atomic replacement is unavailable' - ) from exc - raise - except Exception: - try: - session.invoke('remove', temporary_unc) - except Exception: - pass + raise FileConflict( + 'Atomic replacement is unavailable' + ) from exc + raise + except BaseException: + if ( + remote_file is not None + and not installed + and cleanup_allowed + ): + try: + session.invoke( + 'delete_open_handle_verified', remote_file + ) + except Exception: + pass raise + finally: + if remote_file is not None: + try: + self._close_handle_or_taint_session( + session, + remote_file, + ) + except Exception: + if installed: + raise def iter_tree( self, @@ -513,6 +797,7 @@ def iter_tree( cancel_event, follow_links=False, io_lane='control', + _expected_identities=None, ): if follow_links: raise SMBBackendError('Following reparse points is unavailable') @@ -526,58 +811,92 @@ def cancelled(): def iterate(): with lane_lock: - self._validate_path_components( - actual, root, session=session - ) - - def walk(directory, depth=0): + def walk( + directory, + depth=0, + expected_identities=None, + parent_iterator=None, + parent_entry=None, + ): if depth > 50: raise SMBBackendError( 'Maximum directory depth exceeded' ) if cancelled(): - raise SMBBackendError('Operation cancelled') - iterator = session.invoke( - 'scandir_no_follow', - directory.to_unc(actual.target_ip, actual.share), - ) + raise FileOperationCancelled('Operation cancelled') + if parent_iterator is None: + iterator = session.invoke( + 'scandir_verified', + directory.to_unc( + actual.target_ip, + actual.share, + ), + expected_identities=expected_identities, + ) + else: + try: + iterator = parent_iterator.open_child_directory( + parent_entry + ) + except SMBProtocolError as exc: + if ( + exc.public_code + in _SOURCE_CHANGED_PROTOCOL_CODES + ): + raise FileSourceChanged( + 'The enumerated remote tree changed ' + 'during traversal' + ) from exc + raise try: + identity_chain = tuple( + self._stable_identity(value) + for value in iterator.identity_chain + ) + if len(identity_chain) != len(directory.segments): + raise SMBBackendError( + 'SMB object identity is unavailable' + ) for entry in iterator: member_budget.consume() if cancelled(): - raise SMBBackendError('Operation cancelled') - try: - child = directory.child(entry.name) - except SMBPathRejected as exc: - raise SMBBackendError( - 'Unsafe directory response' - ) from exc - file_stat = entry.stat(follow_symlinks=False) - is_reparse = ( - self._is_reparse(file_stat) - or entry.is_symlink() - ) - is_directory = bool( - entry.is_dir(follow_symlinks=False) - and not is_reparse + raise FileOperationCancelled( + 'Operation cancelled' + ) + payload = self._directory_payload( + directory, + entry, + identity_chain=identity_chain, ) - yield { - 'name': entry.name, - 'path': str(child), - 'size': getattr(file_stat, 'st_size', 0) or 0, - 'mode': getattr(file_stat, 'st_mode', 0), - 'is_dir': is_directory, - 'is_symlink': is_reparse, - } - if is_directory: - yield from walk(child, depth + 1) + yield payload + if payload['is_dir']: + yield from walk( + self._path(payload['path']), + depth + 1, + parent_iterator=iterator, + parent_entry=entry, + ) finally: - try: - iterator.close() - except Exception: - pass + iterator.close() - yield from walk(root) + try: + yield from walk( + root, + expected_identities=_expected_identities, + ) + except SMBProtocolError as exc: + if ( + exc.public_code == 'CONFLICT' + or ( + _expected_identities is not None + and exc.public_code + in _SOURCE_CHANGED_PROTOCOL_CODES + ) + ): + raise FileSourceChanged( + 'The enumerated remote tree changed during traversal' + ) from exc + raise return iterate() @@ -643,6 +962,21 @@ def _editor_revision_locked(self, actual, destination): raise SMBBackendError('File too large to edit') return hashlib.sha256(data).hexdigest() + @staticmethod + def _editor_revision_from_handle(remote_file, info): + """Hash bytes from the exact handle that will later be renamed.""" + try: + size = int(info.end_of_file) + except (TypeError, ValueError, AttributeError) as exc: + raise SMBBackendError('File metadata is unavailable') from exc + if size < 0 or size > config.MAX_EDITOR_FILE_SIZE: + raise SMBBackendError('File too large to edit') + remote_file.seek(0) + data = remote_file.read(config.MAX_EDITOR_FILE_SIZE + 1) + if len(data) > config.MAX_EDITOR_FILE_SIZE: + raise SMBBackendError('File too large to edit') + return hashlib.sha256(data).hexdigest() + @staticmethod def _edit_conflict(): return FileWriteOutcome( @@ -655,15 +989,186 @@ def _edit_conflict(): def _generated_leaf(destination, purpose, token): return f'.{destination.name}.webssh-{purpose}-{token}' - def _remove_generated(self, actual, path): + @staticmethod + def _open_handle_location(session, remote_file, candidates): + """Locate an open FILEID without reopening candidate pathnames.""" + for label, candidate in candidates: + try: + matches = session.invoke( + 'open_handle_matches_path_verified', + remote_file, + candidate, + ) + except Exception: + return None + if matches is True: + return label + if matches is not False: + return None + return None + + def _rename_open_handle_reconciled( + self, + session, + remote_file, + source_unc, + destination_unc, + *, + replace, + ): + """Resolve a lost rename response through the same open FILEID.""" + try: + session.invoke( + 'rename_open_handle_verified', + remote_file, + destination_unc, + replace=replace, + ) + return True, None + except Exception as error: + location = self._open_handle_location( + session, + remote_file, + ( + ('destination', destination_unc), + ('source', source_unc), + ), + ) + if location == 'destination': + return True, None + if location == 'source': + return False, error + return None, error + + def _delete_generated_handle(self, actual, remote_file): try: actual.session.invoke( - 'remove', path.to_unc(actual.target_ip, actual.share) + 'delete_open_handle_verified', remote_file ) return True except Exception: return False + @staticmethod + def _close_handle_or_taint_session(session, remote_file): + """Close one handle, or make a pre-send close failure non-reusable. + + smbprotocol marks the handle closed even when the server's CLOSE reply + is lost. If it is still open, close the owning protocol session so an + exclusive FILEID cannot make a successfully installed destination + inaccessible to subsequent connections. + """ + try: + remote_file.close() + return False + except BaseException as close_error: + control_flow_error = not isinstance(close_error, Exception) + try: + if remote_file.closed: + if control_flow_error: + raise close_error + return False + except (AttributeError, RuntimeError): + pass + close_session = getattr(session, 'close', None) + if not callable(close_session): + raise close_error + try: + close_session() + except BaseException: + raise close_error + if getattr(session, '_closed', False) is not True: + try: + if remote_file.closed: + if control_flow_error: + raise close_error + return False + except (AttributeError, RuntimeError): + pass + raise close_error + if control_flow_error: + raise close_error + return True + + def _rollback_interrupted_recoverable_replace( + self, + actual, + destination_handle, + temporary_handle, + *, + destination_unc, + temporary_unc, + recovery_unc, + ): + """Best-effort rollback after a non-Exception control-flow abort. + + Every decision is based on the two already-open FILEIDs. Unknown + locations retain both artifacts rather than risking deletion through a + stale pathname. + """ + try: + original_location = ( + self._open_handle_location( + actual.session, + destination_handle, + ( + ('destination', destination_unc), + ('recovery', recovery_unc), + ), + ) + if destination_handle is not None + else None + ) + temporary_location = ( + self._open_handle_location( + actual.session, + temporary_handle, + ( + ('destination', destination_unc), + ('temporary', temporary_unc), + ), + ) + if temporary_handle is not None + else None + ) + + if original_location == 'destination': + if temporary_location == 'temporary': + self._delete_generated_handle(actual, temporary_handle) + return + if original_location != 'recovery': + return + + if temporary_location == 'destination': + moved_back, _move_error = ( + self._rename_open_handle_reconciled( + actual.session, + temporary_handle, + destination_unc, + temporary_unc, + replace=False, + ) + ) + if moved_back is not True: + return + temporary_location = 'temporary' + if temporary_location != 'temporary': + return + + restored, _restore_error = self._rename_open_handle_reconciled( + actual.session, + destination_handle, + recovery_unc, + destination_unc, + replace=False, + ) + if restored is True: + self._delete_generated_handle(actual, temporary_handle) + except BaseException: + # Preserve the original control-flow exception. Any ambiguous + # identity is intentionally left intact for manual recovery. + return + def _recoverable_replace( self, actual, @@ -672,7 +1177,24 @@ def _recoverable_replace( *, expected_revision, ): - """Install an editor save with a sibling backup and bounded rollback.""" + with actual.lock: + with self._mutation_guard(actual, destination): + return self._recoverable_replace_guarded( + actual, + destination, + data, + expected_revision=expected_revision, + ) + + def _recoverable_replace_guarded( + self, + actual, + destination, + data, + *, + expected_revision, + ): + """Install an editor save using only identity-bound open handles.""" token = secrets.token_hex(12) temporary = destination.parent().child( self._generated_leaf(destination, 'write', token) + '.tmp' @@ -684,89 +1206,219 @@ def _recoverable_replace( temporary_unc = temporary.to_unc(actual.target_ip, actual.share) recovery_unc = recovery.to_unc(actual.target_ip, actual.share) - with actual.lock: - try: - current_revision = self._editor_revision_locked( - actual, destination - ) - except Exception as exc: - return FileWriteOutcome( - success=False, - error=self._public_error(exc), - ) - if not expected_revision or expected_revision != current_revision: - return self._edit_conflict() - - try: - self._validate_path_components( - actual, temporary, allow_missing_leaf=True - ) - self._validate_path_components( - actual, recovery, allow_missing_leaf=True - ) - remote_file = actual.session.invoke( - 'open_file_no_follow', - temporary_unc, - mode='xb', - buffering=0, - ) - with remote_file: - self._write_all(remote_file, data) - except Exception as exc: - self._remove_generated(actual, temporary) - return FileWriteOutcome( - success=False, - error=self._public_error(exc), - ) + destination_handle = None + temporary_handle = None + replacement_installed = False + try: + with actual.lock: + try: + destination_handle, destination_info = ( + actual.session.invoke( + 'open_file_move_verified', destination_unc + ) + ) + current_revision = self._editor_revision_from_handle( + destination_handle, + destination_info, + ) + except Exception as exc: + return FileWriteOutcome( + success=False, + error=self._public_error(exc), + ) + if ( + not expected_revision + or expected_revision != current_revision + ): + return self._edit_conflict() - try: - self._validate_path_components(actual, destination) - actual.session.invoke('rename', destination_unc, recovery_unc) - except Exception as exc: - removed = self._remove_generated(actual, temporary) - if not removed: + try: + self._validate_path_components( + actual, temporary, allow_missing_leaf=True + ) + self._validate_path_components( + actual, recovery, allow_missing_leaf=True + ) + temporary_handle = actual.session.invoke( + 'create_file_move_verified', + temporary_unc, + ) + self._write_all(temporary_handle, data) + except Exception as exc: + if ( + temporary_handle is not None + and not self._delete_generated_handle( + actual, temporary_handle + ) + ): + return FileWriteOutcome( + success=False, + error=( + 'The save failed and a temporary recovery ' + 'file remains.' + ), + code='SMB_RECOVERY_REQUIRED', + recovery_leaves=(temporary.name,), + ) return FileWriteOutcome( success=False, - error='The save failed and a temporary recovery file remains.', - code='SMB_RECOVERY_REQUIRED', - recovery_leaves=(temporary.name,), + error=self._public_error(exc), ) - return FileWriteOutcome( - success=False, - error=self._public_error(exc), + + committed, rename_error = self._rename_open_handle_reconciled( + actual.session, + destination_handle, + destination_unc, + recovery_unc, + replace=False, ) + if committed is not True: + if committed is None: + return FileWriteOutcome( + success=False, + error=( + 'The save outcome is uncertain. Manual ' + 'recovery may be required.' + ), + code='SMB_RECOVERY_REQUIRED', + recovery_leaves=( + temporary.name, + recovery.name, + ), + ) + removed = self._delete_generated_handle( + actual, temporary_handle + ) + if not removed: + return FileWriteOutcome( + success=False, + error=( + 'The save failed and a temporary recovery ' + 'file remains.' + ), + code='SMB_RECOVERY_REQUIRED', + recovery_leaves=(temporary.name,), + ) + return FileWriteOutcome( + success=False, + error=self._public_error(rename_error), + ) - try: - actual.session.invoke('rename', temporary_unc, destination_unc) - except Exception: - try: - actual.session.invoke('rename', recovery_unc, destination_unc) - except Exception: + installed, _install_error = self._rename_open_handle_reconciled( + actual.session, + temporary_handle, + temporary_unc, + destination_unc, + replace=False, + ) + if installed is not True: + if installed is None: + return FileWriteOutcome( + success=False, + error=( + 'The replacement outcome is uncertain. ' + 'Manual recovery is required.' + ), + code='SMB_RECOVERY_REQUIRED', + recovery_leaves=( + temporary.name, + recovery.name, + ), + ) + rolled_back, _rollback_error = ( + self._rename_open_handle_reconciled( + actual.session, + destination_handle, + recovery_unc, + destination_unc, + replace=False, + ) + ) + if rolled_back is not True: + return FileWriteOutcome( + success=False, + error=( + 'The replacement and automatic rollback ' + 'failed. Manual recovery is required.' + ), + code='SMB_RECOVERY_REQUIRED', + recovery_leaves=(temporary.name, recovery.name), + ) + if not self._delete_generated_handle( + actual, temporary_handle + ): + return FileWriteOutcome( + success=False, + error=( + 'The original file was restored, but a ' + 'temporary recovery file remains.' + ), + code='SMB_RECOVERY_REQUIRED', + recovery_leaves=(temporary.name,), + ) return FileWriteOutcome( success=False, error=( - 'The replacement and automatic rollback failed. ' - 'Manual recovery is required.' + 'The replacement failed. The original file was ' + 'restored.' ), - code='SMB_RECOVERY_REQUIRED', - recovery_leaves=(temporary.name, recovery.name), + code='SMB_RECOVERABLE_REPLACE_FAILED', ) - self._remove_generated(actual, temporary) - return FileWriteOutcome( - success=False, - error='The replacement failed. The original file was restored.', - code='SMB_RECOVERABLE_REPLACE_FAILED', - ) - revision = hashlib.sha256(data).hexdigest() - if not self._remove_generated(actual, recovery): - return FileWriteOutcome( - success=True, - warning_code='SMB_RECOVERY_BACKUP_RETAINED', - recovery_leaves=(recovery.name,), - revision=revision, + # From this point the new bytes own the destination and the + # recovery handle may already be delete-pending. A control-flow + # abort during backup cleanup must preserve the installed file, + # never attempt to move it away for a rollback whose source may + # no longer exist. + replacement_installed = True + revision = hashlib.sha256(data).hexdigest() + if not self._delete_generated_handle( + actual, destination_handle + ): + return FileWriteOutcome( + success=True, + warning_code='SMB_RECOVERY_BACKUP_RETAINED', + recovery_leaves=(recovery.name,), + revision=revision, + ) + return FileWriteOutcome(success=True, revision=revision) + except BaseException: + if not replacement_installed: + self._rollback_interrupted_recoverable_replace( + actual, + destination_handle, + temporary_handle, + destination_unc=destination_unc, + temporary_unc=temporary_unc, + recovery_unc=recovery_unc, ) - return FileWriteOutcome(success=True, revision=revision) + raise + finally: + session_tainted = False + pending_close_error = None + for remote_file in (temporary_handle, destination_handle): + if remote_file is not None: + if session_tainted: + continue + try: + session_tainted = self._close_handle_or_taint_session( + actual.session, + remote_file, + ) + except BaseException as close_error: + session_tainted = ( + getattr(actual.session, '_closed', False) is True + ) + if ( + pending_close_error is None + and ( + not isinstance(close_error, Exception) + or replacement_installed + ) + ): + pending_close_error = close_error + if pending_close_error is not None: + raise pending_close_error @staticmethod def _decode(data): @@ -921,27 +1573,14 @@ def write_file_text( or expected_revision != current_revision ): return self._edit_conflict() - try: - with self.open_atomic_writer( - source, - path, - replace=True, - cancel_event=None, - ) as remote_file: - self._write_all(remote_file, data) - except NonAtomicOverwriteRequired: - return FileWriteOutcome( - success=False, - error=( - 'This SMB account cannot replace the file ' - 'atomically.' - ), - code='SMB_RECOVERABLE_REPLACE_REQUIRED', - revision=current_revision, - ) return FileWriteOutcome( - success=True, - revision=hashlib.sha256(data).hexdigest(), + success=False, + error=( + 'A recoverable SMB replacement must be confirmed before ' + 'saving this file.' + ), + code='SMB_RECOVERABLE_REPLACE_REQUIRED', + revision=current_revision, ) except Exception as exc: return FileWriteOutcome( diff --git a/app/smb_paths.py b/app/smb_paths.py index 64d94d9e..3d115c3b 100644 --- a/app/smb_paths.py +++ b/app/smb_paths.py @@ -16,6 +16,10 @@ class SMBPathRejected(ValueError): r'(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?\Z', re.IGNORECASE, ) +# Recursive SMB operations already stop after 50 levels. Keep ample headroom +# for an ordinary pre-existing prefix while placing a fixed ceiling on the +# number of verified handles and round trips a single path can require. +SMB_PATH_MAX_COMPONENTS = 128 def _validate_component(value, *, maximum, allow_dollar=False): @@ -53,6 +57,10 @@ def __str__(self): class SMBPath: segments: tuple[str, ...] + def __post_init__(self): + if len(self.segments) > SMB_PATH_MAX_COMPONENTS: + raise SMBPathRejected('SMB path has too many components') + @classmethod def parse(cls, value): if not isinstance(value, str) or not value.startswith('/'): @@ -62,7 +70,11 @@ def parse(cls, value): if len(value) > 4096 or value.endswith('/') or '//' in value: raise SMBPathRejected('Invalid SMB path') segments = tuple( - _validate_component(segment, maximum=255) + _validate_component( + segment, + maximum=255, + allow_dollar=True, + ) for segment in value[1:].split('/') ) return cls(segments) @@ -71,7 +83,13 @@ def __str__(self): return '/' + '/'.join(self.segments) def child(self, name): - return SMBPath(self.segments + (_validate_component(name, maximum=255),)) + return SMBPath(self.segments + ( + _validate_component( + name, + maximum=255, + allow_dollar=True, + ), + )) def parent(self): if not self.segments: diff --git a/app/smb_protocol.py b/app/smb_protocol.py index 2daf86e9..2b26f7e1 100644 --- a/app/smb_protocol.py +++ b/app/smb_protocol.py @@ -7,10 +7,20 @@ from __future__ import annotations +from contextlib import contextmanager +from dataclasses import dataclass, replace from threading import RLock import uuid import smbclient +from smbclient._io import ( + SMBDirectoryIO, + SMBFileIO, + SMBFileTransaction, + SMBRawIO, + query_info, + set_info, +) from smbclient._pool import ClientConfig from smbprotocol.connection import Connection, Dialects from smbprotocol.exceptions import ( @@ -26,7 +36,21 @@ SharingViolation, ) from smbprotocol.header import NtStatus -from smbprotocol.open import CreateOptions, DirectoryAccessMask +from smbprotocol.file_info import ( + FileAllInformation, + FileBasicInformation, + FileDispositionInformation, + FileInternalInformation, + FileRenameInformation, + FileStandardInformation, +) +from smbprotocol.open import ( + CreateOptions, + DirectoryAccessMask, + FileAttributes, + FileInformationClass, + FilePipePrinterAccessMask, +) from smbprotocol.session import Session, SessionFlags from .smb_diagnostics import build_smb_diagnostic @@ -90,6 +114,8 @@ def mapped(code): NtStatus.STATUS_OBJECT_NAME_COLLISION, NtStatus.STATUS_SHARING_VIOLATION, NtStatus.STATUS_DIRECTORY_NOT_EMPTY, + NtStatus.STATUS_FILE_IS_A_DIRECTORY, + NtStatus.STATUS_NOT_A_DIRECTORY, }: return mapped('CONFLICT') return mapped('OPERATION_FAILED') @@ -106,6 +132,1058 @@ def mapped(code): return None +def _stable_identity(value): + """Return one usable server object identity or fail closed.""" + if isinstance(value, bool): + raise SMBProtocolError('IDENTITY_UNAVAILABLE') + try: + identity = int(value) + except (TypeError, ValueError) as exc: + raise SMBProtocolError('IDENTITY_UNAVAILABLE') from exc + if identity <= 0: + raise SMBProtocolError('IDENTITY_UNAVAILABLE') + return identity + + +def _disabled_dfs_lookup(_value): + """Never resolve a configured UNC path through process-global DFS state.""" + return None + + +def _reject_dfs_referral(_referral): + """Fail closed if the pinned smbclient version attempts to cache DFS.""" + raise SMBProtocolError('SHARE_UNAVAILABLE') + + +@dataclass(frozen=True) +class SMBObjectInfo: + """Metadata obtained from an already-bound SMB handle or its parent.""" + + name: str + file_id: int + file_attributes: int + end_of_file: int + last_write_time: float + number_of_links: int + identity_chain: tuple[int, ...] = () + + @property + def smb_info(self): + """Keep the backend entry contract independent of smbclient types.""" + return self + + +def _safe_int(value, *, identity=False): + if identity: + return _stable_identity(value) + if isinstance(value, bool): + raise SMBProtocolError('OPERATION_FAILED') + try: + result = int(value) + except (TypeError, ValueError) as exc: + raise SMBProtocolError('OPERATION_FAILED') from exc + if result < 0: + raise SMBProtocolError('OPERATION_FAILED') + return result + + +def _filetime_seconds(value): + """Convert an SMB FILETIME or datetime-like value to Unix seconds.""" + if hasattr(value, 'timestamp'): + return float(value.timestamp()) + ticks = _safe_int(value) + if ticks == 0: + return 0.0 + # 100-nanosecond ticks between 1601-01-01 and 1970-01-01. + return (ticks - 116444736000000000) / 10_000_000 + + +def _query_open_info(raw, *, name=''): + """Read identity and user-visible metadata from one open handle.""" + with SMBFileTransaction(raw) as transaction: + query_info(transaction, FileBasicInformation) + query_info(transaction, FileInternalInformation) + query_info(transaction, FileStandardInformation) + basic, internal, standard = transaction.results + return SMBObjectInfo( + name=name, + file_id=_stable_identity( + internal['index_number'].get_value() + ), + file_attributes=_safe_int( + basic['file_attributes'].get_value() + ), + end_of_file=_safe_int( + standard['end_of_file'].get_value() + ), + last_write_time=_filetime_seconds( + basic['last_write_time'].get_value() + ), + number_of_links=_safe_int( + standard['number_of_links'].get_value() + ), + ) + + +def _entry_from_directory_info(raw_info): + name = raw_info['file_name'].get_value().decode('utf-16-le') + return SMBObjectInfo( + name=name, + file_id=_stable_identity(raw_info['file_id'].get_value()), + file_attributes=_safe_int( + raw_info['file_attributes'].get_value() + ), + end_of_file=_safe_int(raw_info['end_of_file'].get_value()), + last_write_time=_filetime_seconds( + raw_info['last_write_time'].get_value() + ), + # FILE_ID_FULL_DIRECTORY_INFORMATION does not expose link count. + number_of_links=0, + ) + + +def _query_exact_child(directory, name): + """Resolve one exact child through an already-open parent handle.""" + matches = [] + for raw_info in directory.query_directory( + name, + FileInformationClass.FILE_ID_FULL_DIRECTORY_INFORMATION, + ): + entry = _entry_from_directory_info(raw_info) + if entry.name in {'.', '..'}: + continue + if entry.name.casefold() != name.casefold(): + raise SMBProtocolError('CONFLICT') + matches.append(entry) + if len(matches) > 1: + raise SMBProtocolError('CONFLICT') + if not matches: + raise SMBProtocolError('NOT_FOUND') + return matches[0] + + +def _split_unc(path): + if not isinstance(path, str) or not path.startswith('\\\\'): + raise SMBProtocolError('OPERATION_FAILED') + parts = path[2:].split('\\') + if len(parts) < 2 or any(not part for part in parts): + raise SMBProtocolError('OPERATION_FAILED') + return '\\\\' + '\\'.join(parts[:2]), tuple(parts[2:]) + + +def _validate_open_type(raw, *, is_directory): + attributes = _safe_int(getattr(raw.fd, 'file_attributes', None)) + if attributes & int(FileAttributes.FILE_ATTRIBUTE_REPARSE_POINT): + raise SMBProtocolError('REPARSE_POINT_REJECTED') + actual_directory = bool( + attributes & int(FileAttributes.FILE_ATTRIBUTE_DIRECTORY) + ) + if actual_directory != is_directory: + raise SMBProtocolError('CONFLICT') + + +def _validate_share_binding(raw, path): + """Reject DFS or any tree connection outside the requested share.""" + requested_share, _components = _split_unc(path) + descriptor = getattr(raw, 'fd', None) + tree = getattr(descriptor, 'tree_connect', None) + actual_share = getattr(tree, 'share_name', None) + if ( + not isinstance(actual_share, str) + or actual_share.rstrip('\\').casefold() + != requested_share.rstrip('\\').casefold() + or getattr(tree, 'is_dfs_share', None) is not False + ): + raise SMBProtocolError('SHARE_UNAVAILABLE') + + +def _permission_denied(exc): + if isinstance(exc, SMBProtocolError): + return exc.public_code == 'PERMISSION_DENIED' + mapped = _mapped_protocol_error(exc) + return mapped is not None and mapped.public_code == 'PERMISSION_DENIED' + + +def _verified_directory_flag(info): + attributes = _safe_int(info.file_attributes) + if attributes & int(FileAttributes.FILE_ATTRIBUTE_REPARSE_POINT): + raise SMBProtocolError('REPARSE_POINT_REJECTED') + return bool(attributes & int(FileAttributes.FILE_ATTRIBUTE_DIRECTORY)) + + +def _open_raw( + path, + *, + is_directory, + desired_access, + connection_kwargs, + share_access='rwd', +): + raw_type = SMBDirectoryIO if is_directory else SMBFileIO + raw = None + try: + raw = raw_type( + path, + mode='rb', + share_access=share_access, + desired_access=int(desired_access), + create_options=int(CreateOptions.FILE_OPEN_REPARSE_POINT), + **connection_kwargs, + ) + _validate_share_binding(raw, path) + raw.open() + _validate_share_binding(raw, path) + _validate_open_type(raw, is_directory=is_directory) + return raw + except BaseException: + if raw is not None: + try: + raw.close() + except Exception: + pass + raise + + +def _open_untyped_raw( + path, + *, + desired_access, + connection_kwargs, + share_access='rwd', +): + raw = None + try: + raw = SMBRawIO( + path, + mode='rb', + share_access=share_access, + desired_access=int(desired_access), + create_options=int(CreateOptions.FILE_OPEN_REPARSE_POINT), + **connection_kwargs, + ) + _validate_share_binding(raw, path) + raw.open() + _validate_share_binding(raw, path) + attributes = _safe_int(getattr(raw.fd, 'file_attributes', None)) + if attributes & int(FileAttributes.FILE_ATTRIBUTE_REPARSE_POINT): + raise SMBProtocolError('REPARSE_POINT_REJECTED') + return raw + except BaseException: + if raw is not None: + try: + raw.close() + except Exception: + pass + raise + + +def _open_verified_path( + path, + *, + purpose, + expected_identities=None, + **connection_kwargs, +): + """Bind path components by ID, with a narrow list-denied fallback. + + Normal paths use exact directory queries from the already-open parent. + Some SMB servers deny those queries while still permitting access to a + known child. In that compatibility case we reacquire and hold metadata + handles for every non-root prefix without write or delete sharing, then + perform a second full identity pass. The retained prefix handles bind the + verified namespace while preserving access to known children. + """ + root, components = _split_unc(path) + if expected_identities is None: + expected = None + else: + try: + expected = tuple(expected_identities) + except TypeError as exc: + raise SMBProtocolError('OPERATION_FAILED') from exc + if len(expected) != len(components): + raise SMBProtocolError('OPERATION_FAILED') + expected = tuple(_stable_identity(value) for value in expected) + + list_access = int( + DirectoryAccessMask.FILE_LIST_DIRECTORY + | DirectoryAccessMask.FILE_READ_ATTRIBUTES + ) + read_attributes = int(FilePipePrinterAccessMask.FILE_READ_ATTRIBUTES) + + def leaf_access(): + if purpose == 'directory': + return list_access + if purpose == 'directory_pin': + # A pin only reads metadata and retains the verified handle. Keep + # the baseline ACL contract: traversing through the pinned leaf is + # neither required nor requested. + return read_attributes + if purpose == 'file_read': + return int( + FilePipePrinterAccessMask.FILE_READ_DATA + | FilePipePrinterAccessMask.FILE_READ_ATTRIBUTES + ) + if purpose == 'file_move': + return int( + FilePipePrinterAccessMask.DELETE + | FilePipePrinterAccessMask.FILE_READ_DATA + | FilePipePrinterAccessMask.FILE_READ_ATTRIBUTES + ) + if purpose == 'rename': + return int( + FilePipePrinterAccessMask.DELETE + | FilePipePrinterAccessMask.FILE_READ_ATTRIBUTES + ) + if purpose == 'delete': + return int( + FilePipePrinterAccessMask.DELETE + | FilePipePrinterAccessMask.FILE_READ_ATTRIBUTES + ) + if purpose == 'delete_read_only': + return int( + FilePipePrinterAccessMask.DELETE + | FilePipePrinterAccessMask.FILE_READ_ATTRIBUTES + | FilePipePrinterAccessMask.FILE_WRITE_ATTRIBUTES + ) + return read_attributes + + root_access = ( + list_access + if components or purpose == 'directory' + else read_attributes + ) + root_share_access = ( + 'r' if purpose == 'directory_pin' and not components else 'rwd' + ) + opaque = False + try: + current = _open_raw( + root, + is_directory=True, + desired_access=root_access, + connection_kwargs=connection_kwargs, + share_access=root_share_access, + ) + except Exception as exc: + if ( + not components + or root_access != list_access + or not _permission_denied(exc) + ): + raise + current = _open_raw( + root, + is_directory=True, + desired_access=read_attributes, + connection_kwargs=connection_kwargs, + share_access=root_share_access, + ) + opaque = True + current_path = root + identity_chain = [] + opaque_records = [] + try: + current_info = _query_open_info(current) + + def retain_opaque_prefix(component_count): + """Reacquire prefixes and deny namespace/attribute mutation.""" + nonlocal current, current_info, current_path + + if opaque_records: + return + if component_count == 0: + opaque_records.append((current_path, current, current_info)) + return + + pinned = [] + pin_path = root + try: + for position in range(component_count): + pin_path += '\\' + components[position] + pin = _open_raw( + pin_path, + is_directory=True, + desired_access=read_attributes, + connection_kwargs=connection_kwargs, + share_access='r', + ) + try: + pin_info = _query_open_info( + pin, + name=components[position], + ) + if ( + pin_info.file_id != identity_chain[position] + or not _verified_directory_flag(pin_info) + ): + raise SMBProtocolError('CONFLICT') + except BaseException: + try: + pin.close() + except Exception: + pass + raise + pinned.append((pin_path, pin, pin_info)) + except BaseException: + for _pin_path, pin, _pin_info in reversed(pinned): + try: + pin.close() + except Exception: + pass + raise + + try: + current.close() + except BaseException: + for _pin_path, pin, _pin_info in reversed(pinned): + try: + pin.close() + except Exception: + pass + raise + + opaque_records.extend(pinned) + current_path, current, current_info = pinned[-1] + + if opaque: + opaque_records.append((root, current, current_info)) + if not components: + if purpose in {'file_read', 'file_move', 'rename'}: + raise SMBProtocolError('CONFLICT') + return current, current_info + + for index, component in enumerate(components): + is_leaf = index == len(components) - 1 + child_path = current_path + '\\' + component + + entry = None + if not opaque: + try: + entry = _query_exact_child(current, component) + except Exception as exc: + if not _permission_denied(exc): + raise + retain_opaque_prefix(index) + opaque = True + + if opaque: + desired_access = leaf_access() if is_leaf else read_attributes + if not is_leaf: + child = _open_raw( + child_path, + is_directory=True, + desired_access=desired_access, + connection_kwargs=connection_kwargs, + share_access='r', + ) + elif purpose in {'file_read', 'file_move'}: + child = _open_raw( + child_path, + is_directory=False, + desired_access=desired_access, + connection_kwargs=connection_kwargs, + share_access=( + 'r' if purpose == 'file_move' else 'rwd' + ), + ) + elif purpose in {'directory', 'directory_pin'}: + child = _open_raw( + child_path, + is_directory=True, + desired_access=desired_access, + connection_kwargs=connection_kwargs, + share_access=( + 'r' + if purpose == 'directory_pin' and is_leaf + else 'rwd' + ), + ) + else: + child = _open_untyped_raw( + child_path, + desired_access=desired_access, + connection_kwargs=connection_kwargs, + ) + try: + info = _query_open_info(child, name=component) + is_directory = _verified_directory_flag(info) + if not is_leaf and not is_directory: + raise SMBProtocolError('NOT_FOUND') + if ( + is_leaf + and purpose in {'file_read', 'file_move'} + and is_directory + ): + raise SMBProtocolError('CONFLICT') + if ( + is_leaf + and purpose in {'directory', 'directory_pin'} + and not is_directory + ): + raise SMBProtocolError('CONFLICT') + if expected is not None and ( + info.file_id != expected[index] + ): + raise SMBProtocolError('CONFLICT') + except BaseException: + try: + child.close() + except Exception: + pass + raise + identity_chain.append(info.file_id) + opaque_records.append((child_path, child, info)) + current = child + current_info = info + current_path = child_path + continue + + identity_chain.append(entry.file_id) + is_directory = bool( + entry.file_attributes + & int(FileAttributes.FILE_ATTRIBUTE_DIRECTORY) + ) + if entry.file_attributes & int( + FileAttributes.FILE_ATTRIBUTE_REPARSE_POINT + ): + raise SMBProtocolError('REPARSE_POINT_REJECTED') + if not is_leaf and not is_directory: + raise SMBProtocolError('NOT_FOUND') + if expected is not None and entry.file_id != expected[index]: + raise SMBProtocolError('CONFLICT') + if ( + is_leaf + and purpose in {'file_read', 'file_move'} + and is_directory + ): + raise SMBProtocolError('CONFLICT') + if ( + is_leaf + and purpose in {'directory', 'directory_pin'} + and not is_directory + ): + raise SMBProtocolError('CONFLICT') + + desired_access = leaf_access() if is_leaf else list_access + try: + child = _open_raw( + child_path, + is_directory=is_directory, + desired_access=desired_access, + connection_kwargs=connection_kwargs, + share_access=( + 'r' if is_leaf and purpose in { + 'directory_pin', + 'file_move', + } else 'rwd' + ), + ) + except Exception as exc: + allow_opaque_fallback = ( + is_directory + and _permission_denied(exc) + and (not is_leaf or purpose == 'directory_pin') + ) + if not allow_opaque_fallback: + raise + retain_opaque_prefix(index) + child = _open_raw( + child_path, + is_directory=True, + desired_access=read_attributes, + connection_kwargs=connection_kwargs, + share_access=( + 'r' + if purpose == 'directory_pin' and is_leaf + else ('rwd' if is_leaf else 'r') + ), + ) + opaque = True + try: + info = _query_open_info(child, name=entry.name) + if info.file_id != entry.file_id: + raise SMBProtocolError('CONFLICT') + if _verified_directory_flag(info) != is_directory: + raise SMBProtocolError('CONFLICT') + except BaseException: + try: + child.close() + except Exception: + pass + raise + + if opaque: + opaque_records.append((child_path, child, info)) + else: + try: + current.close() + except BaseException: + try: + child.close() + except Exception: + pass + raise + current = child + current_info = info + current_path = child_path + + if opaque_records: + for verify_path, _held, held_info in opaque_records: + verify_directory = _verified_directory_flag(held_info) + verifier = _open_raw( + verify_path, + is_directory=verify_directory, + # These independent reopens only compare metadata; they + # never descend through the verifier handle. Requesting + # FILE_TRAVERSE here would reject valid list-only ACLs + # without strengthening the identity check. + desired_access=read_attributes, + connection_kwargs=connection_kwargs, + ) + try: + verify_info = _query_open_info(verifier) + if verify_info.file_id != held_info.file_id: + raise SMBProtocolError('CONFLICT') + if ( + _verified_directory_flag(verify_info) + != verify_directory + ): + raise SMBProtocolError('CONFLICT') + finally: + verifier.close() + for _held_path, held, _held_info in opaque_records[:-1]: + held.close() + + info = replace( + current_info, + identity_chain=tuple(identity_chain), + ) + return current, info + except BaseException: + closed = set() + for _held_path, held, _held_info in reversed(opaque_records): + if id(held) in closed: + continue + closed.add(id(held)) + try: + held.close() + except Exception: + pass + if id(current) not in closed: + try: + current.close() + except Exception: + pass + raise + + +class _VerifiedDirectoryIterator: + """Enumerate one verified directory handle and own its lifetime.""" + + def __init__( + self, + raw, + info, + *, + path=None, + connection_kwargs=None, + ): + self._raw = raw + self._path = path + self._connection_kwargs = ( + None if connection_kwargs is None else dict(connection_kwargs) + ) + try: + self._iterator = raw.query_directory( + '*', + FileInformationClass.FILE_ID_FULL_DIRECTORY_INFORMATION, + ) + except BaseException: + try: + raw.close() + except Exception: + pass + raise + self.identity = info.file_id + self.identity_chain = info.identity_chain + self.closed = False + + def __iter__(self): + return self + + def __next__(self): + if self.closed: + raise StopIteration + try: + while True: + entry = _entry_from_directory_info(next(self._iterator)) + if entry.name not in {'.', '..'}: + return entry + except StopIteration: + self.close() + raise + except Exception as exc: + self.close() + mapped = _mapped_protocol_error(exc) + if mapped is not None: + raise mapped from exc + raise + + def open_child_directory(self, entry): + """Open one enumerated child without rewalking the share root.""" + if ( + self.closed + or self._path is None + or self._connection_kwargs is None + ): + raise SMBProtocolError('OPERATION_FAILED') + + child = None + try: + expected_identity = _stable_identity(entry.file_id) + if not _verified_directory_flag(entry): + raise SMBProtocolError('CONFLICT') + child_path = self._path + '\\' + entry.name + child = _open_raw( + child_path, + is_directory=True, + desired_access=int( + DirectoryAccessMask.FILE_LIST_DIRECTORY + | DirectoryAccessMask.FILE_READ_ATTRIBUTES + ), + connection_kwargs=self._connection_kwargs, + ) + info = _query_open_info(child, name=entry.name) + if info.file_id != expected_identity: + raise SMBProtocolError('CONFLICT') + if not _verified_directory_flag(info): + raise SMBProtocolError('CONFLICT') + info = replace( + info, + identity_chain=(*self.identity_chain, info.file_id), + ) + owned_child = child + child = None + return _VerifiedDirectoryIterator( + owned_child, + info, + path=child_path, + connection_kwargs=self._connection_kwargs, + ) + except BaseException as exc: + if child is not None: + try: + child.close() + except Exception: + pass + if isinstance(exc, Exception): + mapped = _mapped_protocol_error(exc) + if mapped is not None: + raise mapped from exc + raise + + def close(self): + if self.closed: + return + self.closed = True + try: + self._iterator.close() + except Exception: + pass + try: + self._raw.close() + except Exception: + pass + + +def _verified_scandir(path, *, expected_identities=None, **kwargs): + raw, info = _open_verified_path( + path, + purpose='directory', + expected_identities=expected_identities, + **kwargs, + ) + return _VerifiedDirectoryIterator( + raw, + info, + path=path, + connection_kwargs=kwargs, + ) + + +def _verified_stat(path, *, expected_identities=None, **kwargs): + raw, info = _open_verified_path( + path, + purpose='stat', + expected_identities=expected_identities, + **kwargs, + ) + try: + return info + finally: + raw.close() + + +def _verified_file_reader(path, *, expected_identities=None, **kwargs): + raw, _info = _open_verified_path( + path, + purpose='file_read', + expected_identities=expected_identities, + **kwargs, + ) + return raw + + +def _verified_file_move_handle( + path, + *, + expected_identities=None, + **kwargs, +): + """Open one exact file for revision reads and same-handle renames.""" + return _open_verified_path( + path, + purpose='file_move', + expected_identities=expected_identities, + **kwargs, + ) + + +def _create_file_move_handle(path, **connection_kwargs): + """Exclusively create a writable file that can be renamed by its handle.""" + raw = None + try: + raw = SMBFileIO( + path, + mode='xb', + share_access=None, + desired_access=int( + FilePipePrinterAccessMask.DELETE + | FilePipePrinterAccessMask.FILE_WRITE_DATA + | FilePipePrinterAccessMask.FILE_READ_ATTRIBUTES + ), + create_options=int(CreateOptions.FILE_OPEN_REPARSE_POINT), + **connection_kwargs, + ) + _validate_share_binding(raw, path) + raw.open() + _validate_share_binding(raw, path) + _validate_open_type(raw, is_directory=False) + return raw + except BaseException: + if raw is not None: + try: + raw.close() + except Exception: + pass + raise + + +def _require_open_handle(raw): + """Reject handles that a transaction helper would reopen by pathname.""" + try: + closed = raw.closed + except (AttributeError, RuntimeError) as exc: + raise SMBProtocolError('OPERATION_FAILED') from exc + if closed is not False: + raise SMBProtocolError('OPERATION_FAILED') + + +def _open_handle_matches_path(raw, path, **_connection_kwargs): + """Compare a candidate path with the name bound to one open FILEID.""" + _require_open_handle(raw) + _validate_share_binding(raw, path) + _root, expected_components = _split_unc(path) + if not expected_components: + raise SMBProtocolError('OPERATION_FAILED') + + # smbprotocol 1.17's FileNameInformation parser intentionally has no + # INFO_TYPE/INFO_CLASS metadata, so it cannot be passed directly to + # smbclient.query_info(). FileAllInformation is the supported query class + # that contains the same name structure. Its default output allocation is + # only the fixed 100-byte prefix, therefore reserve enough room for the + # bounded share-relative path as well. + with SMBFileTransaction(raw) as transaction: + query_info( + transaction, + FileAllInformation, + output_buffer_length=65536, + ) + try: + current_name = ( + transaction.results[0]['name_information'] + .get_value()['file_name'] + .get_value() + ) + except (AttributeError, IndexError, KeyError, TypeError) as exc: + raise SMBProtocolError('OPERATION_FAILED') from exc + if isinstance(current_name, bytes): + try: + current_name = current_name.decode('utf-16-le') + except UnicodeDecodeError as exc: + raise SMBProtocolError('OPERATION_FAILED') from exc + if not isinstance(current_name, str): + raise SMBProtocolError('OPERATION_FAILED') + current_name = current_name.removeprefix('\\') + current_components = tuple(current_name.split('\\')) + if ( + len(current_components) != len(expected_components) + or any( + not component + or component in {'.', '..'} + or '\x00' in component + for component in current_components + ) + ): + return False + return tuple( + component.casefold() for component in current_components + ) == tuple(component.casefold() for component in expected_components) + + +def _rename_open_handle( + raw, + destination, + *, + replace=False, + **_connection_kwargs, +): + """Rename the object bound to ``raw`` without resolving its source again.""" + _require_open_handle(raw) + if not isinstance(replace, bool): + raise SMBProtocolError('OPERATION_FAILED') + _validate_share_binding(raw, destination) + _root, components = _split_unc(destination) + if not components: + raise SMBProtocolError('OPERATION_FAILED') + rename_info = FileRenameInformation() + rename_info['replace_if_exists'] = bool(replace) + rename_info['root_directory'] = 0 + rename_info['file_name'] = '\\'.join(components) + with SMBFileTransaction(raw) as transaction: + set_info(transaction, rename_info) + + +def _verified_rename( + path, + destination, + *, + replace=False, + expected_identities=None, + **kwargs, +): + raw, _info = _open_verified_path( + path, + purpose='rename', + expected_identities=expected_identities, + **kwargs, + ) + try: + _rename_open_handle(raw, destination, replace=replace) + finally: + raw.close() + + +def _verified_directory_handle(path, *, expected_identities=None, **kwargs): + return _open_verified_path( + path, + purpose='directory_pin', + expected_identities=expected_identities, + **kwargs, + ) + + +def _set_delete_disposition(raw, **_connection_kwargs): + _require_open_handle(raw) + disposition = FileDispositionInformation() + disposition['delete_pending'] = True + with SMBFileTransaction(raw) as transaction: + set_info(transaction, disposition) + + +def _set_file_attributes(raw, attributes): + basic_info = FileBasicInformation() + basic_info['creation_time'] = 0 + basic_info['last_access_time'] = 0 + basic_info['last_write_time'] = 0 + basic_info['change_time'] = 0 + basic_info['file_attributes'] = int(attributes) + with SMBFileTransaction(raw) as transaction: + set_info(transaction, basic_info) + + +def _is_read_only_delete_failure(exc, attributes): + try: + status = int(getattr(exc, 'ntstatus')) + attributes = int(attributes) + except (AttributeError, TypeError, ValueError): + return False + return ( + status == int(NtStatus.STATUS_CANNOT_DELETE) + and bool( + attributes + & int(FileAttributes.FILE_ATTRIBUTE_READONLY) + ) + ) + + +def _verified_delete(path, *, expected_identities=None, **kwargs): + """Set delete disposition with the minimum rights on a verified handle.""" + raw, info = _open_verified_path( + path, + purpose='delete', + expected_identities=expected_identities, + **kwargs, + ) + direct_failure = None + try: + try: + _set_delete_disposition(raw) + return + except Exception as exc: + if not _is_read_only_delete_failure( + exc, + info.file_attributes, + ): + raise + direct_failure = exc + + fallback = None + try: + fallback, fallback_info = _open_verified_path( + path, + purpose='delete_read_only', + expected_identities=info.identity_chain, + **kwargs, + ) + if fallback_info.file_id != info.file_id: + raise SMBProtocolError('CONFLICT') + original_attributes = int(fallback_info.file_attributes) + read_only = int(FileAttributes.FILE_ATTRIBUTE_READONLY) + if not original_attributes & read_only: + raise SMBProtocolError('CONFLICT') from direct_failure + writable_attributes = original_attributes & ~read_only + if writable_attributes == 0: + writable_attributes = int( + FileAttributes.FILE_ATTRIBUTE_NORMAL + ) + try: + _set_file_attributes(fallback, writable_attributes) + _set_delete_disposition(fallback) + except BaseException as fallback_error: + try: + _set_file_attributes(fallback, original_attributes) + except BaseException as restore_error: + raise restore_error from fallback_error + raise + finally: + if fallback is not None: + fallback.close() + finally: + raw.close() + + class _MappedIterator: """Keep deferred SMB directory failures inside the protocol boundary.""" @@ -139,8 +1217,18 @@ def __init__(self, key, connection): self._sealed = True def get(self, key, default=None): - if self._sealed and key != self._key: - raise SMBProtocolError('SOURCE_UNAVAILABLE') + if self._sealed: + if key != self._key: + raise SMBProtocolError('SOURCE_UNAVAILABLE') + connection = super().get(key) + transport = getattr(connection, 'transport', None) + if ( + connection is None + or transport is None + or not getattr(transport, 'connected', False) + ): + raise SMBProtocolError('SOURCE_UNAVAILABLE') + return connection return super().get(key, default) def __setitem__(self, key, value): @@ -153,7 +1241,17 @@ class _RealSMBProtocol: smb_3_1_1 = Dialects.SMB_3_1_1 def configure_global(self, **kwargs): - ClientConfig(**kwargs) + config = ClientConfig(**kwargs) + # smbprotocol 1.17 stores DFS caches on a process-global singleton, + # and its high-level mutation helpers can consult them even when + # skip_dfs is set. This module is pinned to that dependency version; + # clear and override the private referral hooks so an approved + # \\server\share path can never be silently rebound to another share. + config._referral_cache = [] + config._domain_cache = [] + config.lookup_referral = _disabled_dfs_lookup + config.lookup_domain = _disabled_dfs_lookup + config.cache_referral = _reject_dfs_referral def new_connection( self, @@ -215,7 +1313,24 @@ def session_is_guest_or_null(session): @staticmethod def close_connection(connection, *, timeout): - connection.disconnect(close=True, timeout=timeout) + try: + connection.disconnect(close=True, timeout=timeout) + except BaseException: + try: + # smbprotocol closes its transport only after every session, + # tree, and open has logged off. If that cleanup fails, force + # the transport-only path so its receiver thread cannot outlive + # the source that owned it. + connection.disconnect(close=False, timeout=timeout) + except BaseException: + transport = getattr(connection, 'transport', None) + close_transport = getattr(transport, 'close', None) + if callable(close_transport): + try: + close_transport() + except BaseException: + pass + raise @staticmethod def invoke(name, *args, **kwargs): @@ -230,10 +1345,32 @@ def invoke(name, *args, **kwargs): int(kwargs.get('create_options', 0)) | int(CreateOptions.FILE_OPEN_REPARSE_POINT) ) - operation = getattr(smbclient, name, None) - if operation is None or name.startswith('_'): - raise SMBProtocolError('OPERATION_UNAVAILABLE') try: + if name == 'scandir_verified': + return _verified_scandir(*args, **kwargs) + if name == 'stat_verified': + return _verified_stat(*args, **kwargs) + if name == 'open_file_verified': + return _verified_file_reader(*args, **kwargs) + if name == 'open_file_move_verified': + return _verified_file_move_handle(*args, **kwargs) + if name == 'create_file_move_verified': + return _create_file_move_handle(*args, **kwargs) + if name == 'open_directory_verified': + return _verified_directory_handle(*args, **kwargs) + if name == 'delete_verified': + return _verified_delete(*args, **kwargs) + if name == 'delete_open_handle_verified': + return _set_delete_disposition(*args, **kwargs) + if name == 'open_handle_matches_path_verified': + return _open_handle_matches_path(*args, **kwargs) + if name == 'rename_open_handle_verified': + return _rename_open_handle(*args, **kwargs) + if name == 'rename_verified': + return _verified_rename(*args, **kwargs) + operation = getattr(smbclient, name, None) + if operation is None or name.startswith('_'): + raise SMBProtocolError('OPERATION_UNAVAILABLE') result = operation(*args, **kwargs) return _MappedIterator(result) if name == 'scandir' else result except Exception as exc: @@ -268,6 +1405,7 @@ def __init__( self.secure_negotiate = True self._lock = RLock() self._closed = False + self._mutation_guard_depth = 0 self.connection_cache = _SealedConnectionCache( f'{target_ip.lower()}:445', raw_connection, @@ -283,6 +1421,35 @@ def invoke(self, name, *args, **kwargs): with self._lock: self._ensure_alive() + open_mode = kwargs.get('mode', 'rb') + mutating_open = ( + name in {'open_file', 'open_file_no_follow'} + and ( + not isinstance(open_mode, str) + or '+' in open_mode + or any( + marker in open_mode.lower() + for marker in ('a', 'w', 'x') + ) + ) + ) + if ( + name in { + 'create_file_move_verified', + 'delete_open_handle_verified', + 'delete_verified', + 'mkdir_no_follow', + 'open_file_move_verified', + 'remove', + 'rename', + 'rename_open_handle_verified', + 'rename_verified', + 'replace', + 'rmdir', + } + or mutating_open + ) and self._mutation_guard_depth < 1: + raise SMBProtocolError('MUTATION_GUARD_REQUIRED') call_kwargs = { 'username': self.raw_session.username, 'password': None, @@ -301,9 +1468,58 @@ def invoke(self, name, *args, **kwargs): except Exception as exc: raise SMBProtocolError('OPERATION_FAILED') from exc + @contextmanager + def pin_directories(self, paths, *, expected_identities=None): + """Hold verified handles with best-effort final share denial.""" + handles = [] + identities = {} + expected_identities = expected_identities or {} + with self._lock: + self._ensure_alive() + try: + for path in dict.fromkeys(paths): + handle, info = self.invoke( + 'open_directory_verified', + path, + ) + handles.append(handle) + expected = expected_identities.get(path) + identity = _stable_identity(info.file_id) + if ( + expected is not None + and identity != _stable_identity(expected) + ): + raise SMBProtocolError('CONFLICT') + identities[path] = identity + yield identities + finally: + with self._lock: + for handle in reversed(handles): + try: + handle.close() + except Exception: + pass + + @contextmanager + def pin_mutation_ancestors(self, paths, *, expected_identities=None): + """Hold verified directory identities while permitting mutations.""" + entered = False + with self._lock: + with self.pin_directories( + paths, + expected_identities=expected_identities, + ) as identities: + try: + self._mutation_guard_depth += 1 + entered = True + yield identities + finally: + if entered: + self._mutation_guard_depth -= 1 + def inspect_directory_access(self, path): """Validate listing and query root directory rights without mutation.""" - iterator = self.invoke('scandir_no_follow', path) + iterator = self.invoke('scandir_verified', path) try: next(iter(iterator), None) finally: diff --git a/app/socket_capacity.py b/app/socket_capacity.py index 71c5c992..4c29996c 100644 --- a/app/socket_capacity.py +++ b/app/socket_capacity.py @@ -1,41 +1,89 @@ from collections import defaultdict +from contextlib import contextmanager from threading import RLock class SocketCapacityRegistry: - """Track process-local Socket.IO capacity for the single-worker runtime.""" + """Track process-local connection capacity for the single-worker runtime.""" def __init__(self): self._lock = RLock() self._owners = {} self._by_user = defaultdict(set) + self._terminal = set() + self._admission_locks = {} + + @contextmanager + def _reservation_lock(self, socket_sid): + """Hold the lock belonging to one exact live reservation.""" + while True: + with self._lock: + admission_lock = self._admission_locks.get(socket_sid) + if admission_lock is None: + yield False + return + admission_lock.acquire() + with self._lock: + if self._admission_locks.get(socket_sid) is admission_lock: + break + admission_lock.release() + try: + yield True + finally: + admission_lock.release() def reserve(self, user_id, socket_sid, max_total, max_per_user): """Atomically reserve one socket slot if both limits allow it.""" user_id = int(user_id) with self._lock: if socket_sid in self._owners: - return self._owners[socket_sid] == user_id + return ( + self._owners[socket_sid] == user_id + and socket_sid not in self._terminal + ) if len(self._owners) >= max_total: return False if len(self._by_user[user_id]) >= max_per_user: return False self._owners[socket_sid] = user_id self._by_user[user_id].add(socket_sid) + self._admission_locks[socket_sid] = RLock() return True def release(self, socket_sid): """Release a socket slot and return its recorded owner, if any.""" - with self._lock: - user_id = self._owners.pop(socket_sid, None) - if user_id is None: + with self._reservation_lock(socket_sid) as reserved: + if not reserved: return None - user_sockets = self._by_user.get(user_id) - if user_sockets is not None: - user_sockets.discard(socket_sid) - if not user_sockets: - self._by_user.pop(user_id, None) - return user_id + with self._lock: + user_id = self._owners.pop(socket_sid, None) + self._terminal.discard(socket_sid) + self._admission_locks.pop(socket_sid, None) + if user_id is None: + return None + user_sockets = self._by_user.get(user_id) + if user_sockets is not None: + user_sockets.discard(socket_sid) + if not user_sockets: + self._by_user.pop(user_id, None) + return user_id + + def mark_terminal(self, socket_sid): + """Atomically terminalize one reservation and return its owner once.""" + with self._reservation_lock(socket_sid) as reserved: + if not reserved: + return None + with self._lock: + user_id = self._owners.get(socket_sid) + if user_id is None or socket_sid in self._terminal: + return None + self._terminal.add(socket_sid) + return user_id + + def is_terminal(self, socket_sid): + """Return whether a reserved transport must reject further binding.""" + with self._lock: + return socket_sid in self._terminal def count_for_user(self, user_id): """Return the number of process-local sockets owned by one user.""" @@ -43,5 +91,41 @@ def count_for_user(self, user_id): with self._lock: return len(self._by_user.get(user_id, ())) + def sids_for_user(self, user_id): + """Snapshot exact transport IDs owned by one user.""" + user_id = int(user_id) + with self._lock: + return tuple(self._by_user.get(user_id, ())) + + def sids(self): + """Snapshot every exact transport ID currently reserved.""" + with self._lock: + return tuple(self._owners) + + def owner(self, socket_sid): + """Return the recorded owner without changing the reservation.""" + with self._lock: + return self._owners.get(socket_sid) + + def count(self): + """Return the total number of process-local reservations.""" + with self._lock: + return len(self._owners) + + @contextmanager + def admission_guard(self, socket_sid, user_id): + """Linearize namespace setup against terminalization and release.""" + user_id = int(user_id) + with self._reservation_lock(socket_sid) as reserved: + if not reserved: + yield False + return + with self._lock: + admitted = ( + self._owners.get(socket_sid) == user_id + and socket_sid not in self._terminal + ) + yield admitted + socket_capacity = SocketCapacityRegistry() diff --git a/app/socket_events.py b/app/socket_events.py index 4d83b5d8..e0c81e11 100644 --- a/app/socket_events.py +++ b/app/socket_events.py @@ -1,5 +1,12 @@ -from flask_socketio import emit, join_room, disconnect +from flask_socketio import ( + ConnectionRefusedError, + disconnect, + emit, + join_room, +) from flask import copy_current_request_context, request, current_app, url_for +from contextlib import contextmanager +from functools import wraps from . import (socketio, ssh_manager, profile_manager, key_manager, sftp_handler, jump_host_manager, post_connect_manager, session_insights, runtime_inventory, smb_share_manager) @@ -26,6 +33,7 @@ ) from .storage_errors import StorageCorruptionError from .command_storage_policy import CommandStorageLimitError +from .connection_storage_policy import ConnectionStorageLimitError from .network_policy import canonicalize_hostname from .ssh_errors import connection_error_payload from . import binary_transfer, connection_pool @@ -34,6 +42,10 @@ from .socket_capacity import socket_capacity from .ssh_input_budget import budget_from_config from .ssh_output_flow import ssh_output_flow +from .socket_protocol import ( + SOCKET_PROTOCOL_MISMATCH_EVENT, + SOCKET_WIRE_REVISION, +) from .remote_transfer import ( RemoteTransferCancelled, RemoteTransferError, @@ -52,6 +64,7 @@ ) from .file_service import file_service from .smb_diagnostics import smb_diagnostic_log_fields +import hashlib import posixpath import re import secrets @@ -66,12 +79,47 @@ _SMB_REQUEST_ID = re.compile(r'[A-Za-z0-9:._-]{1,128}') +_FILE_SOURCE_ID_MAX_BYTES = 160 +_FILE_CONTROL_SMALL_TEXT_MAX_BYTES = 128 +_FILE_CONTROL_PATH_FIELDS = frozenset({ + 'remote_path', + 'path', + 'old_path', + 'new_path', + 'source_path', + 'dest_path', +}) +_FILE_CONTROL_SOURCE_FIELDS = frozenset({ + 'source_id', + 'destination_source_id', +}) +_FILE_CONTROL_SMALL_FIELDS = frozenset({ + 'request_id', + 'listing_request_id', + 'transfer_id', + 'expected_revision', + 'direction', + 'encoding', + 'newline', + 'replace_strategy', + 'save_challenge', + 'conflict_policy', +}) +_FILE_CONTROL_CHECKED = object() +_file_control_budgets = {} +_editor_save_budgets = {} +_editor_retry_challenges = {} +_file_control_budget_lock = threading.Lock() +_EDITOR_RETRY_CHALLENGE_TTL_SECONDS = 120.0 +_EDITOR_RETRY_CHALLENGE_MAX_STATES = 256 +_EDITOR_RETRY_CHALLENGE_MAX_PER_USER = 4 _SMB_CONNECT_CODES = frozenset({ 'AUTHENTICATION_REQUIRED', 'CONNECTION_FAILED', 'CONNECT_CANCELLED', 'DIALECT_REQUIRED', 'ENCRYPTION_REQUIRED', + 'IDENTITY_UNAVAILABLE', 'INVALID_REQUEST', 'PERMISSION_DENIED', 'QUOTA_EXCEEDED', @@ -81,6 +129,8 @@ 'TARGET_NOT_ALLOWED', 'TIMEOUT', }) +_ENGINEIO_REJECTION_DRAIN_SECONDS = 1.0 +_ENGINEIO_REJECTION_POLL_SECONDS = 0.01 _smb_attempts_lock = threading.RLock() _smb_attempts = {} _ssh_banner_prompts_lock = threading.RLock() @@ -137,10 +187,81 @@ def _cancel_smb_attempts_for_socket(user_id, socket_sid): handle.cancel() -def _file_request_identity(payload): +def _file_request_identity( + payload, + user_id=None, + *, + allow_editor_content=False, + editor_budget_exempt=False, +): + if isinstance(payload, dict) and payload.get( + '_file_control_checked' + ) is not _FILE_CONTROL_CHECKED: + if user_id is None: + try: + socket_user = get_user_from_socket(request.sid) + user_id = socket_user.id if socket_user is not None else None + except Exception: + user_id = None + budget_now = time.monotonic() + reserved = user_id is not None and _consume_file_control_budget( + user_id, 256, now=budget_now + ) + if not reserved: + _sanitize_file_control_payload(payload) + valid = False + else: + editor_body = ( + allow_editor_content + and isinstance(payload.get('content'), str) + ) + editor_reserved = ( + not editor_body + or editor_budget_exempt + or _consume_editor_save_budget( + user_id, + 1, + now=budget_now, + ) + ) + if not editor_reserved: + _sanitize_file_control_payload(payload) + valid = False + else: + byte_count, editor_bytes = _file_control_payload_metrics( + payload, + allow_editor_content=allow_editor_content, + ) + valid = _sanitize_file_control_payload(payload) + remainder = max(0, byte_count - 256) + if remainder and not _consume_file_control_budget( + user_id, + remainder, + now=budget_now, + ): + valid = False + if ( + editor_body + and not editor_budget_exempt + and editor_bytes is not None + and editor_bytes > 1 + and not _consume_editor_save_budget( + user_id, + editor_bytes - 1, + now=budget_now, + ) + ): + valid = False + payload['_file_control_valid'] = valid + payload['_file_control_checked'] = _FILE_CONTROL_CHECKED + if isinstance(payload, dict) and not payload.get( + '_file_control_valid', False + ): + payload['source_id'] = None request_id = payload.get('request_id') if ( not isinstance(request_id, str) + or len(request_id) > 128 or not re.fullmatch(r'[A-Za-z0-9:._-]{1,128}', request_id) ): request_id = None @@ -150,6 +271,333 @@ def _file_request_identity(payload): } +def _utf8_text_within(value, maximum): + if not isinstance(value, str) or len(value) > maximum: + return False + try: + return len(value.encode('utf-8')) <= maximum + except UnicodeEncodeError: + return False + + +def _bounded_utf8_size(value, maximum): + """Measure UTF-8 incrementally and stop before an oversized full copy.""" + if not isinstance(value, str) or len(value) > maximum: + return None + size = 0 + for offset in range(0, len(value), 4096): + try: + size += len(str(value[offset:offset + 4096]).encode('utf-8')) + except UnicodeEncodeError: + return None + if size > maximum: + return None + return size + + +def _file_control_payload_metrics(payload, *, allow_editor_content=False): + """Bound all control metadata without reserializing the attacker payload. + + Editor content is measured separately so it can retain its larger + legitimate per-file allowance without weakening the tighter metadata + bucket. Every other value, including unknown or nested fields, is charged + here so a caller cannot hide an editor-envelope-sized allocation behind a + harmless request. + """ + maximum_charge = config.FILE_CONTROL_BYTES_PER_MINUTE + 1 + cost = 0 + editor_bytes = 0 + stack = [(payload, 0, False)] + visited = 0 + while stack: + value, depth, editor_content = stack.pop() + visited += 1 + if visited > 1024 or depth > 8: + return maximum_charge, None + if editor_content: + size = _bounded_utf8_size( + value, + config.MAX_EDITOR_FILE_SIZE, + ) + if size is None: + return maximum_charge, None + editor_bytes += size + continue + if isinstance(value, dict): + if len(value) > 1024: + return maximum_charge, None + for key, item in value.items(): + if not isinstance(key, str) or len(key) > 256: + return maximum_charge, None + try: + cost += len(key.encode('utf-8')) + except UnicodeEncodeError: + return maximum_charge, None + stack.append(( + item, + depth + 1, + allow_editor_content + and depth == 0 + and key == 'content' + and isinstance(item, str), + )) + elif isinstance(value, (list, tuple)): + if len(value) > 1024: + return maximum_charge, None + for item in value: + stack.append((item, depth + 1, False)) + elif isinstance(value, str): + size = _bounded_utf8_size( + value, + max(0, config.FILE_CONTROL_BYTES_PER_MINUTE - cost), + ) + if size is None: + return maximum_charge, None + cost += size + elif isinstance(value, (bytes, bytearray, memoryview)): + cost += len(value) + else: + # JSON scalars and unexpected objects still consume parser and + # object memory; use a small conservative accounting charge. + cost += 16 + if cost > config.FILE_CONTROL_BYTES_PER_MINUTE: + return maximum_charge, None + # A conservative floor also bounds CPU/event amplification independently + # of how little metadata a syntactically empty request carries. + return max(256, cost), editor_bytes + + +def _file_control_payload_cost(payload, *, allow_editor_content=False): + """Compatibility wrapper returning the metadata charge only.""" + cost, _editor_bytes = _file_control_payload_metrics( + payload, + allow_editor_content=allow_editor_content, + ) + return cost + + +def _consume_file_control_budget(user_id, byte_count, now=None): + """Apply a constant-memory per-user token bucket to control metadata.""" + current = time.monotonic() if now is None else float(now) + key = int(user_id) + capacity = config.FILE_CONTROL_BYTES_PER_MINUTE + with _file_control_budget_lock: + available, updated_at = _file_control_budgets.get( + key, + (float(capacity), current), + ) + elapsed = max(0.0, current - updated_at) + available = min( + float(capacity), + available + (elapsed * capacity / 60.0), + ) + if byte_count > capacity or byte_count > available: + # Oversized attempts exhaust the bucket too; otherwise an attacker + # could repeat rejected editor-envelope allocations for free. + _file_control_budgets[key] = (0.0, current) + return False + _file_control_budgets[key] = (available - byte_count, current) + return True + + +def _consume_editor_save_budget(user_id, byte_count, now=None): + """Charge accepted editor bodies against an exact per-user byte bucket.""" + current = time.monotonic() if now is None else float(now) + key = int(user_id) + capacity = config.EDITOR_SAVE_BYTES_PER_MINUTE + with _file_control_budget_lock: + available, updated_at = _editor_save_budgets.get( + key, + (float(capacity), current), + ) + elapsed = max(0.0, current - updated_at) + available = min( + float(capacity), + available + (elapsed * capacity / 60.0), + ) + if byte_count > capacity or byte_count > available: + _editor_save_budgets[key] = (0.0, current) + return False + _editor_save_budgets[key] = (available - byte_count, current) + return True + + +def _current_socket_sid(): + try: + socket_sid = request.sid + except (AttributeError, RuntimeError): + return '' + return socket_sid if isinstance(socket_sid, str) else '' + + +def _editor_retry_fingerprint(payload, user_id, socket_sid): + """Build a bounded identity for one exact editor body and destination.""" + if not isinstance(payload, dict): + return None + source_id = payload.get('source_id') + path = payload.get('path') + content = payload.get('content') + encoding = payload.get('encoding', 'utf-8') + newline = payload.get('newline', 'lf') + expected_revision = payload.get('expected_revision') + if ( + not _utf8_text_within(source_id, _FILE_SOURCE_ID_MAX_BYTES) + or not _utf8_text_within( + path, + config.FILE_CONTROL_MAX_PATH_BYTES, + ) + or not _utf8_text_within( + encoding, + _FILE_CONTROL_SMALL_TEXT_MAX_BYTES, + ) + or not _utf8_text_within( + newline, + _FILE_CONTROL_SMALL_TEXT_MAX_BYTES, + ) + or ( + expected_revision is not None + and not _utf8_text_within( + expected_revision, + _FILE_CONTROL_SMALL_TEXT_MAX_BYTES, + ) + ) + or not isinstance(content, str) + or len(content) > config.MAX_EDITOR_FILE_SIZE + ): + return None + + digest = hashlib.sha256() + byte_count = 0 + for offset in range(0, len(content), 4096): + try: + encoded = content[offset:offset + 4096].encode('utf-8') + except UnicodeEncodeError: + return None + byte_count += len(encoded) + if byte_count > config.MAX_EDITOR_FILE_SIZE: + return None + digest.update(encoded) + return ( + int(user_id), + socket_sid, + source_id, + path, + encoding, + newline, + expected_revision, + byte_count, + digest.hexdigest(), + ) + + +def _prune_editor_retry_challenges_locked(now): + for token, state in tuple(_editor_retry_challenges.items()): + if state['expires_at'] <= now: + _editor_retry_challenges.pop(token, None) + + +def _issue_editor_retry_challenge(payload, user_id, socket_sid=None): + """Authorize one same-body recoverable retry without a second byte charge.""" + socket_sid = _current_socket_sid() if socket_sid is None else socket_sid + fingerprint = _editor_retry_fingerprint(payload, user_id, socket_sid) + if fingerprint is None: + return None + now = time.monotonic() + with _file_control_budget_lock: + _prune_editor_retry_challenges_locked(now) + owned = [ + (state['issued_at'], token) + for token, state in _editor_retry_challenges.items() + if state['fingerprint'][0] == int(user_id) + ] + while len(owned) >= _EDITOR_RETRY_CHALLENGE_MAX_PER_USER: + _issued_at, oldest = min(owned) + _editor_retry_challenges.pop(oldest, None) + owned = [entry for entry in owned if entry[1] != oldest] + while ( + len(_editor_retry_challenges) + >= _EDITOR_RETRY_CHALLENGE_MAX_STATES + ): + oldest = min( + _editor_retry_challenges, + key=lambda token: _editor_retry_challenges[token]['issued_at'], + ) + _editor_retry_challenges.pop(oldest, None) + token = secrets.token_urlsafe(32) + while token in _editor_retry_challenges: + token = secrets.token_urlsafe(32) + _editor_retry_challenges[token] = { + 'fingerprint': fingerprint, + 'user_id': int(user_id), + 'socket_sid': socket_sid, + 'issued_at': now, + 'expires_at': now + _EDITOR_RETRY_CHALLENGE_TTL_SECONDS, + } + return token + + +def _consume_editor_retry_challenge(payload, user_id, socket_sid=None): + """Consume a valid challenge exactly once and bind it to the same save.""" + if ( + not isinstance(payload, dict) + or payload.get('replace_strategy') != 'recoverable_swap' + ): + return False + token = payload.get('save_challenge') + if ( + not isinstance(token, str) + or re.fullmatch(r'[A-Za-z0-9_-]{32,64}', token) is None + ): + return False + socket_sid = _current_socket_sid() if socket_sid is None else socket_sid + now = time.monotonic() + with _file_control_budget_lock: + _prune_editor_retry_challenges_locked(now) + state = _editor_retry_challenges.get(token) + if ( + state is None + or state['expires_at'] <= now + or state['user_id'] != int(user_id) + or state['socket_sid'] != socket_sid + ): + return False + # Claim the unguessable, coarsely bound token before doing any + # body-sized work. Invalid/replayed tokens therefore reach the normal + # metadata/editor budget gates without hashing their content first, + # while concurrent replays cannot both receive an exemption. + _editor_retry_challenges.pop(token, None) + fingerprint = _editor_retry_fingerprint(payload, user_id, socket_sid) + return ( + fingerprint is not None + and state['fingerprint'] == fingerprint + ) + + +def _sanitize_file_control_payload(payload): + """Bound file-control metadata before it is copied, logged, or emitted.""" + if not isinstance(payload, dict): + return False + invalid = False + limits = ( + (_FILE_CONTROL_PATH_FIELDS, config.FILE_CONTROL_MAX_PATH_BYTES), + (_FILE_CONTROL_SOURCE_FIELDS, _FILE_SOURCE_ID_MAX_BYTES), + (_FILE_CONTROL_SMALL_FIELDS, _FILE_CONTROL_SMALL_TEXT_MAX_BYTES), + ) + for fields, maximum in limits: + for field in fields: + if field not in payload or payload[field] is None: + continue + if not _utf8_text_within(payload[field], maximum): + payload[field] = None + invalid = True + if invalid: + # Every file handler already rejects a missing source ID before backend + # resolution. Clearing it also prevents exception paths from reflecting + # any other invalid control field. + payload['source_id'] = None + return not invalid + + def _file_request_source_id(payload, user_id): source_id = payload.get('source_id') if source_id: @@ -161,6 +609,24 @@ def _valid_file_request(identity): return bool(identity.get('source_id') and identity.get('request_id')) +def _valid_directory_cursor(cursor): + return ( + cursor == 0 + or ( + isinstance(cursor, str) + and 1 <= len(cursor) <= 160 + and re.fullmatch(r'[A-Za-z0-9._-]+', cursor) is not None + ) + ) + + +def _valid_directory_request_id(request_id): + return ( + isinstance(request_id, str) + and _SMB_REQUEST_ID.fullmatch(request_id) is not None + ) + + def _public_file_source(source_id, user_id): return file_source_resolver.resolve( source_id, @@ -318,9 +784,323 @@ def _validate_ssh_params(host, port, username, allow_internal=False): return canonicalize_hostname(host), port, username, None + +def _socket_wire_revision(auth): + if not isinstance(auth, dict): + return None + revision = auth.get('wire_revision') + return revision if type(revision) is int else None + + +def _engineio_transport_is_admitted(user): + """Bind the Socket.IO namespace identity to its admitted transport.""" + engineio_sid = socketio.server.manager.eio_sid_from_sid(request.sid, '/') + if engineio_sid is None: + return False + if engineio_sid not in socketio.server.eio.sockets: + # Flask-SocketIO's in-process test client bypasses Engine.IO and has no + # transport to retain. Keep that test-only adapter compatible, while a + # missing transport in a serving process remains a fail-closed state. + return bool(current_app.testing) + capacity = current_app.extensions.get('engineio_socket_capacity') + return ( + capacity is not None + and capacity.owner(engineio_sid) == int(user.id) + and not capacity.is_terminal(engineio_sid) + ) + + +@contextmanager +def _engineio_namespace_admission_guard(user): + """Linearize namespace initialization against transport revocation.""" + engineio_sid = socketio.server.manager.eio_sid_from_sid(request.sid, '/') + if engineio_sid is None: + yield False + return + engineio_socket = socketio.server.eio.sockets.get(engineio_sid) + if engineio_socket is None: + # The in-process Flask-SocketIO test adapter has no Engine.IO socket. + yield bool(current_app.testing) + return + capacity = current_app.extensions.get('engineio_socket_capacity') + if capacity is None: + yield False + return + with capacity.admission_guard(engineio_sid, user.id) as admitted: + yield ( + admitted + and socketio.server.eio.sockets.get(engineio_sid) + is engineio_socket + ) + + +def _engineio_cleanup_context( + server=None, + namespace_sid=None, + *, + engineio_sid=None, +): + """Capture one exact transport before its namespace mapping is removed.""" + server = socketio.server if server is None else server + manager = getattr(server, 'manager', None) + engineio_server = getattr(server, 'eio', None) + if manager is None or engineio_server is None: + return None + if engineio_sid is None: + if namespace_sid is None: + try: + namespace_sid = request.sid + except (AttributeError, RuntimeError): + return None + try: + engineio_sid = manager.eio_sid_from_sid(namespace_sid, '/') + except Exception: + return None + if engineio_sid is None: + return None + engineio_socket = engineio_server.sockets.get(engineio_sid) + capacity = getattr(engineio_server, '_webssh_socket_capacity', None) + if capacity is None: + try: + capacity = current_app.extensions.get('engineio_socket_capacity') + except RuntimeError: + capacity = None + if engineio_socket is None or capacity is None: + return None + owner_id = capacity.owner(engineio_sid) + if owner_id is None: + return None + return ( + server, + manager, + engineio_server, + engineio_sid, + engineio_socket, + capacity, + owner_id, + ) + + +def _terminalize_engineio_cleanup(cleanup_context): + if cleanup_context is None: + return False + _server, _manager, _eio, engineio_sid, _socket, capacity, owner_id = ( + cleanup_context + ) + return capacity.mark_terminal(engineio_sid) == owner_id + + +def _abort_exact_engineio_transport(cleanup_context): + """Abort only the captured socket and idempotently release its slot.""" + ( + _server, + _manager, + engineio_server, + engineio_sid, + engineio_socket, + capacity, + owner_id, + ) = cleanup_context + if capacity.owner(engineio_sid) != owner_id: + return + current_socket = engineio_server.sockets.get(engineio_sid) + if current_socket is None: + capacity.release(engineio_sid) + return + if current_socket is not engineio_socket: + return + try: + engineio_socket.close( + wait=False, + abort=True, + reason=engineio_server.reason.SERVER_DISCONNECT, + ) + except Exception: + pass + finally: + if engineio_server.sockets.get(engineio_sid) is engineio_socket: + engineio_server.sockets.pop(engineio_sid, None) + capacity.release(engineio_sid) + + +def _schedule_engineio_cleanup(cleanup_context, *, drain=True): + """Drain queued advisories briefly, then release a terminal transport.""" + if cleanup_context is None: + return + ( + _server, + manager, + engineio_server, + engineio_sid, + engineio_socket, + _capacity, + _owner_id, + ) = cleanup_context + + def close_after_drain(): + initialization_done = getattr( + engineio_socket, + '_webssh_initialization_done', + None, + ) + while ( + initialization_done is not None + and not initialization_done.is_set() + and not getattr(engineio_socket, 'connected', False) + and engineio_server.sockets.get(engineio_sid) is engineio_socket + ): + try: + engineio_server.sleep(_ENGINEIO_REJECTION_POLL_SECONDS) + except Exception: + time.sleep(_ENGINEIO_REJECTION_POLL_SECONDS) + if drain: + deadline = time.monotonic() + _ENGINEIO_REJECTION_DRAIN_SECONDS + while time.monotonic() < deadline: + try: + namespace_gone = manager.sid_from_eio_sid( + engineio_sid, + '/', + ) is None + except Exception: + namespace_gone = False + try: + queue_empty = engineio_socket.queue.empty() + except Exception: + queue_empty = False + if namespace_gone and queue_empty: + break + try: + engineio_server.sleep(_ENGINEIO_REJECTION_POLL_SECONDS) + except Exception: + time.sleep(_ENGINEIO_REJECTION_POLL_SECONDS) + _abort_exact_engineio_transport(cleanup_context) + + try: + engineio_server.start_background_task(close_after_drain) + except Exception: + initialization_done = getattr( + engineio_socket, + '_webssh_initialization_done', + None, + ) + initialization_owns_socket = ( + initialization_done is not None + and not initialization_done.is_set() + and not getattr(engineio_socket, 'connected', False) + and engineio_server.sockets.get(engineio_sid) is engineio_socket + ) + if initialization_owns_socket: + # Never remove a socket while Engine.IO's _handle_connect frame + # still owns its unconditional rejection cleanup. The configured + # runtime is threading, so retain the same asynchronous guarantee + # even if Engine.IO's task helper itself fails. + try: + threading.Thread( + target=close_after_drain, + name='webssh-engineio-cleanup', + daemon=True, + ).start() + except Exception: + # The terminal reservation still prevents namespace binding. + # Popping here would race Engine.IO and turn a clean 401 into + # a server-side KeyError. + return + else: + # Scheduling failure on an initialized socket is safe to complete + # synchronously and must not retain its capacity slot. + _abort_exact_engineio_transport(cleanup_context) + + +def disconnect_socket_transport(server, namespace_sid, *, drain=True): + """Disconnect a namespace and retire its exact Engine.IO transport.""" + if server is None: + return False + cleanup_context = _engineio_cleanup_context(server, namespace_sid) + terminalized = _terminalize_engineio_cleanup(cleanup_context) + try: + server.disconnect(namespace_sid, namespace='/') + finally: + if terminalized: + _schedule_engineio_cleanup(cleanup_context, drain=drain) + return True + + +def disconnect_engineio_transport(server, engineio_sid, *, drain=False): + """Retire an admitted transport even before a namespace is connected.""" + if server is None: + return False + cleanup_context = _engineio_cleanup_context( + server, + engineio_sid=engineio_sid, + ) + if not _terminalize_engineio_cleanup(cleanup_context): + return False + _server, manager, _eio, captured_sid, *_rest = cleanup_context + try: + try: + namespace_sid = manager.sid_from_eio_sid(captured_sid, '/') + except Exception: + namespace_sid = None + if namespace_sid is not None: + server.disconnect(namespace_sid, namespace='/') + finally: + _schedule_engineio_cleanup( + cleanup_context, + drain=drain and namespace_sid is not None, + ) + return True + + +def _close_transport_after_rejected_connect(handler): + """Ensure every unsuccessful namespace connect releases Engine.IO.""" + @wraps(handler) + def wrapped(*args, **kwargs): + cleanup_context = _engineio_cleanup_context() + accepted = False + try: + result = handler(*args, **kwargs) + accepted = result is not False + return result + finally: + if not accepted: + if _terminalize_engineio_cleanup(cleanup_context): + _schedule_engineio_cleanup(cleanup_context) + + return wrapped + + +def _reject_socket_protocol_mismatch(auth, user): + received_revision = _socket_wire_revision(auth) + message = 'WebSSH was updated. Reload this page to continue.' + mismatch_payload = { + 'status': 'reload_required', + 'code': SOCKET_PROTOCOL_MISMATCH_EVENT, + 'message': message, + 'required_revision': SOCKET_WIRE_REVISION, + } + if received_revision is not None: + mismatch_payload['received_revision'] = received_revision + socket_sid = request.sid + log_warning( + 'Socket wire revision mismatch', + user=user.username, + sid=socket_sid, + received_revision=received_revision, + required_revision=SOCKET_WIRE_REVISION, + ) + raise ConnectionRefusedError(message, mismatch_payload) + + @socketio.on('connect') -def handle_connect(): +@_close_transport_after_rejected_connect +def handle_connect(auth=None): """Handle client connection - authenticate and restore sessions.""" + from .maintenance_mode import is_active + + if is_active(): + emit('connected', {'status': 'unavailable'}) + return False + from flask import session as flask_session user_id = flask_session.get('_user_id') @@ -343,6 +1123,19 @@ def handle_connect(): disconnect() return False + if user.is_ldap_managed: + from .ldap_session import ldap_revocation_pending + + if ldap_revocation_pending(current_app, user.id): + log_warning( + 'Socket connection rejected by pending LDAP revocation', + user_id=user.id, + sid=request.sid, + ) + emit('connected', {'status': 'unauthenticated'}) + disconnect() + return False + from .auth_assurance import ( current_authentication_session, recovery_session_required, @@ -358,43 +1151,72 @@ def handle_connect(): disconnect() return False - socket_sid = request.sid - if not socket_capacity.reserve( - user.id, - socket_sid, - config.MAX_SOCKET_CONNECTIONS, - config.MAX_SOCKET_CONNECTIONS_PER_USER, - ): - log_warning( - 'Socket connection capacity reached', - user_id=user.id, - sid=socket_sid, - ) - emit('connected', {'status': 'unavailable'}) - disconnect() - return False - - ssh_output_flow.register_socket(socket_sid) - - user_agent = request.headers.get('User-Agent', '') - try: - register_socket_session(user.id, socket_sid, user_agent) - except Exception: - ssh_output_flow.release_socket(socket_sid) - socket_capacity.release(socket_sid) - raise + if _socket_wire_revision(auth) != SOCKET_WIRE_REVISION: + return _reject_socket_protocol_mismatch(auth, user) - room = f'user_{user.id}' - join_room(room) + socket_sid = request.sid + with _engineio_namespace_admission_guard(user) as admitted: + if not admitted or not _engineio_transport_is_admitted(user): + log_warning( + 'Socket connection rejected without transport admission', + user_id=user.id, + sid=socket_sid, + ) + return False - log_info(f"Client connected: {user.username}", user=user.username, sid=socket_sid) + if not socket_capacity.reserve( + user.id, + socket_sid, + config.MAX_SOCKET_CONNECTIONS, + config.MAX_SOCKET_CONNECTIONS_PER_USER, + ): + log_warning( + 'Socket connection capacity reached', + user_id=user.id, + sid=socket_sid, + ) + emit('connected', {'status': 'unavailable'}) + disconnect() + return False - restore_user_sessions(user.id, socket_sid) + ssh_output_flow.register_socket(socket_sid) + user_agent = request.headers.get('User-Agent', '') + try: + register_socket_session(user.id, socket_sid, user_agent) + room = f'user_{user.id}' + join_room(room) - emit('connected', { - 'status': 'success', - 'username': user.username - }) + log_info( + f"Client connected: {user.username}", + user=user.username, + sid=socket_sid, + ) + restore_user_sessions(user.id, socket_sid) + emit('connected', { + 'status': 'success', + 'username': user.username, + 'wire_revision': SOCKET_WIRE_REVISION, + }) + except BaseException: + ssh_output_flow.release_socket(socket_sid) + socket_capacity.release(socket_sid) + try: + SocketSession.query.filter_by( + socket_sid=socket_sid, + ).delete(synchronize_session=False) + db.session.commit() + except BaseException as cleanup_error: + try: + db.session.rollback() + except BaseException: + pass + log_error( + 'Socket connect rollback failed', + user_id=user.id, + sid=socket_sid, + exception_type=type(cleanup_error).__name__, + ) + raise @socketio.on('disconnect') def handle_disconnect(): @@ -435,6 +1257,11 @@ def handle_disconnect(): exception_type=type(error).__name__, ) + file_service.discard_directory_snapshots( + user_id=user_id, + client_id=socket_sid, + ) + # The process-local capacity registry is authoritative for this # single-worker runtime and remains available if persistent socket # metadata cannot be updated during a database outage. @@ -680,6 +1507,10 @@ def request_auth_banner_decision(banner, context): if live_jump_host.get('auth_type') == 'password': proxy_jump['password'] = runtime_password + if auth_type == 'tailscale' and proxy_jump: + emit_error('Tailscale SSH cannot be used with a jump host') + return + # Preserve precise missing-reference errors without running PBKDF2 or # decrypting attacker-selected stored keys before the attempt budget. if ( @@ -749,6 +1580,7 @@ def request_auth_banner_decision(banner, context): current_user, host, username, + port=port, ) ) log_tailscale_ssh_usage( @@ -1177,20 +2009,31 @@ def handle_ssh_disconnect(data, current_user=None): except Exception: emit('ssh_error', {'error': 'Disconnect failed'}) + +def _public_profile(current_user, stored_profile): + """Return a response copy with authorization derived from live policy.""" + profile = dict(stored_profile) + profile.pop('tailscale_authorized', None) + if profile.get('auth_type') == 'tailscale': + profile['tailscale_authorized'] = profile_is_authorized_for_launch( + current_user, + profile, + ) + return profile + + @socketio.on('list_profiles') @socket_login_required def handle_list_profiles(current_user=None): """Return list of saved connection profiles for this user.""" try: - profiles = [] - for stored_profile in profile_manager.load_profiles(current_user.id): - profile = dict(stored_profile) - if profile.get('auth_type') == 'tailscale': - profile['tailscale_authorized'] = ( - profile_is_authorized_for_launch(current_user, profile) - ) - profiles.append(profile) + profiles = [ + _public_profile(current_user, stored_profile) + for stored_profile in profile_manager.load_profiles(current_user.id) + ] emit('profiles_list', {'profiles': profiles}) + except ConnectionStorageLimitError as error: + return _command_set_error(str(error)) except StorageCorruptionError as error: return _emit_storage_error(error, current_user) except Exception as e: @@ -1202,13 +2045,22 @@ def handle_list_profiles(current_user=None): def handle_save_profile(data, current_user=None): """Create or update a connection profile without starting SSH.""" try: - data = data if isinstance(data, dict) else {} + limited = _connection_mutation_rate_limit(current_user) + if limited: + return limited + data = dict(data) if isinstance(data, dict) else {} + data.pop('tailscale_authorized', None) auth_type = data.get('auth_type') host = data.get('host') username = data.get('username') if auth_type == 'tailscale': - access_error = validate_tailscale_ssh_access(current_user, host, username) + access_error = validate_tailscale_ssh_access( + current_user, + host, + username, + port=data.get('port', 22), + ) if access_error: emit('error', {'error': access_error}) return {'success': False, 'error': access_error} @@ -1219,9 +2071,9 @@ def handle_save_profile(data, current_user=None): emit('error', {'error': error}) return {'success': False, 'error': error} else: - payload = {'success': True, 'profile': profile} + response_profile = _public_profile(current_user, profile) + payload = {'success': True, 'profile': response_profile} emit('profile_saved', payload) - handle_list_profiles(current_user=current_user) return payload except StorageCorruptionError as error: @@ -1236,6 +2088,10 @@ def handle_save_profile(data, current_user=None): def handle_delete_profile(data, current_user=None): """Delete a connection profile for this user.""" try: + limited = _connection_mutation_rate_limit(current_user) + if limited: + return limited + data = data if isinstance(data, dict) else {} profile_id = data.get('profile_id') if not profile_id: emit('error', {'error': 'Profile ID required'}) @@ -1246,7 +2102,6 @@ def handle_delete_profile(data, current_user=None): return _command_set_error(error) payload = {'success': True, 'profile_id': profile_id} emit('profile_deleted', payload) - handle_list_profiles(current_user=current_user) return payload except StorageCorruptionError as storage_error: @@ -1261,6 +2116,9 @@ def handle_delete_profile(data, current_user=None): def handle_update_profile_organization(data, current_user=None): """Update grouping metadata without resubmitting connection secrets.""" try: + limited = _connection_mutation_rate_limit(current_user) + if limited: + return limited data = data if isinstance(data, dict) else {} profile_id = data.get('profile_id') if not isinstance(profile_id, str) or not profile_id: @@ -1277,9 +2135,11 @@ def handle_update_profile_organization(data, current_user=None): ) if error: return {'success': False, 'error': error} - payload = {'success': True, 'profile': profile} + payload = { + 'success': True, + 'profile': _public_profile(current_user, profile), + } emit('profile_organization_updated', payload) - handle_list_profiles(current_user=current_user) return payload except StorageCorruptionError as error: return _emit_storage_error(error, current_user) @@ -1296,6 +2156,9 @@ def handle_update_profile_organization(data, current_user=None): def handle_move_profile(data, current_user=None): """Move one profile atomically within the user's flat group structure.""" try: + limited = _connection_mutation_rate_limit(current_user) + if limited: + return limited data = data if isinstance(data, dict) else {} profile_id = data.get('profile_id') if not isinstance(profile_id, str) or not profile_id: @@ -1329,17 +2192,51 @@ def handle_move_profile(data, current_user=None): confirm_source_group_removal=confirmed, ) if error: + organization = [ + { + 'id': profile.get('id'), + 'group': profile.get('group', ''), + 'sort_order': profile.get('sort_order', 0), + } + for profile in (result or {}).get('profiles', ()) + if isinstance(profile.get('id'), str) + ] return { 'success': False, 'error': error, - **(result or {}), + 'requires_confirmation': bool( + (result or {}).get('requires_confirmation') + ), + 'organization': organization, } if result.get('requires_confirmation'): - return {'success': False, **result} + return { + 'success': False, + 'requires_confirmation': True, + 'profile_id': result.get('profile_id'), + 'profile_name': result.get('profile_name', ''), + 'source_group': result.get('source_group', ''), + } - payload = {'success': True, **result} + organization = [ + { + 'id': profile.get('id'), + 'group': profile.get('group', ''), + 'sort_order': profile.get('sort_order', 0), + **( + {'updated_at': profile['updated_at']} + if 'updated_at' in profile else {} + ), + } + for profile in result.get('profiles', ()) + if isinstance(profile.get('id'), str) + ] + payload = { + 'success': True, + 'requires_confirmation': False, + 'organization': organization, + } emit('profile_organization_updated', payload) - handle_list_profiles(current_user=current_user) return payload except StorageCorruptionError as error: return _emit_storage_error(error, current_user) @@ -1353,6 +2250,8 @@ def handle_list_jump_hosts(current_user=None): """Return list of saved jump hosts for this user.""" try: emit('jump_hosts_list', {'jump_hosts': jump_host_manager.load_jump_hosts(current_user.id)}) + except ConnectionStorageLimitError as error: + return _command_set_error(str(error)) except StorageCorruptionError as error: return _emit_storage_error(error, current_user) except Exception as e: @@ -1364,6 +2263,10 @@ def handle_list_jump_hosts(current_user=None): def handle_save_jump_host(data, current_user=None): """Save a new jump host (bastion) for this user. Never stores a password.""" try: + limited = _connection_mutation_rate_limit(current_user) + if limited: + return limited + data = data if isinstance(data, dict) else {} jump_host, error = jump_host_manager.add_jump_host( user_id=current_user.id, name=data.get('name'), @@ -1374,10 +2277,11 @@ def handle_save_jump_host(data, current_user=None): key_id=data.get('key_id') ) if error: - emit('error', {'error': error}) + return _command_set_error(error) else: - emit('jump_host_saved', {'jump_host': jump_host}) - handle_list_jump_hosts(current_user=current_user) + payload = {'success': True, 'jump_host': jump_host} + emit('jump_host_saved', payload) + return payload except StorageCorruptionError as error: return _emit_storage_error(error, current_user) except Exception as e: @@ -1389,6 +2293,10 @@ def handle_save_jump_host(data, current_user=None): def handle_delete_jump_host(data, current_user=None): """Delete a jump host for this user.""" try: + limited = _connection_mutation_rate_limit(current_user) + if limited: + return limited + data = data if isinstance(data, dict) else {} jump_host_id = data.get('jump_host_id') if not jump_host_id: emit('error', {'error': 'Jump host ID required'}) @@ -1397,9 +2305,9 @@ def handle_delete_jump_host(data, current_user=None): current_user.id, jump_host_id ) if success: - emit('jump_host_deleted', {'jump_host_id': jump_host_id}) - handle_list_jump_hosts(current_user=current_user) - return {'success': True, 'jump_host_id': jump_host_id} + payload = {'success': True, 'jump_host_id': jump_host_id} + emit('jump_host_deleted', payload) + return payload else: return _command_set_error(error, usages) except StorageCorruptionError as error: @@ -1638,6 +2546,48 @@ def emit_result(*, success, available=False): emit_result(success=True, available=available is True) +@socketio.on('cancel_directory_listing') +@socket_login_required +def handle_cancel_directory_listing(data, current_user=None): + """Release one exact paginated listing without exposing its existence.""" + payload = data if isinstance(data, dict) else {} + identity = _file_request_identity(payload, current_user.id) + cursor = payload.get('cursor') + listing_request_id = payload.get('listing_request_id') + valid_cursor = cursor != 0 and _valid_directory_cursor(cursor) + valid_request_id = _valid_directory_request_id(listing_request_id) + if ( + _valid_file_request(identity) + and (valid_cursor or valid_request_id) + ): + try: + try: + client_id = request.sid + except (AttributeError, RuntimeError): + client_id = None + if valid_cursor: + file_service.cancel_directory_snapshot( + cursor, + user_id=current_user.id, + source_id=identity['source_id'], + client_id=client_id, + ) + if valid_request_id: + file_service.cancel_directory_request( + listing_request_id, + user_id=current_user.id, + source_id=identity['source_id'], + client_id=client_id, + ) + except Exception as error: + log_error( + 'Directory listing cancellation failed', + user_id=current_user.id, + exception_type=type(error).__name__, + ) + return {'success': True} + + @socketio.on('list_directory') @socket_login_required def handle_list_directory(data, current_user=None): @@ -1646,28 +2596,43 @@ def handle_list_directory(data, current_user=None): _t0 = _time.time() try: payload = data if isinstance(data, dict) else {} - identity = _file_request_identity(payload) + identity = _file_request_identity(payload, current_user.id) source_id = identity.get('source_id') remote_path = payload.get('remote_path', '.') + cursor = payload.get('cursor', 0) request_context = { 'operation': 'list_directory', **identity, 'path': remote_path, } - if not _valid_file_request(identity): + if ( + not _valid_file_request(identity) + or not _valid_directory_cursor(cursor) + ): emit('error', { 'error': 'Source ID and request ID required', **request_context, }) return + if cursor != 0: + request_context['cursor'] = cursor + try: _t1 = _time.time() - files, error = file_service.list_directory( + try: + client_id = request.sid + except (AttributeError, RuntimeError): + # Direct unit invocation has no active Socket.IO request. + client_id = None + files, error, next_cursor = file_service.list_directory_page( source_id, user_id=current_user.id, path=remote_path, + cursor=cursor, + client_id=client_id, + request_id=identity['request_id'], ) except FileSourceUnavailable: log_warning( @@ -1689,16 +2654,23 @@ def handle_list_directory(data, current_user=None): **identity, 'path': remote_path, 'files': files, + 'cursor': cursor, + 'next_cursor': next_cursor, }) except Exception as e: log_error("list_directory exception", error=str(e), elapsed_ms=int((_time.time()-_t0)*1000)) - emit('error', { + error_payload = { 'error': 'Failed to list directory', 'operation': 'list_directory', **_file_request_identity(payload), 'path': payload.get('remote_path', '.'), - }) + } + if _valid_directory_cursor(payload.get('cursor', 0)): + cursor = payload.get('cursor', 0) + if cursor != 0: + error_payload['cursor'] = cursor + emit('error', error_payload) @socketio.on('set_theme') @socket_login_required @@ -2006,6 +2978,19 @@ def handle_delete_command(data, current_user=None): _COMMAND_MUTATION_RATE_ERROR = ( 'Too many command changes. Please wait before trying again.' ) +_CONNECTION_MUTATION_RATE_ERROR = ( + 'Too many saved-connection changes. Please wait before trying again.' +) + + +def _connection_mutation_rate_limit(current_user): + if config.RATELIMIT_ENABLED and check_socket_rate_limit( + current_user.id, + 'connection_mutation', + config.RATELIMIT_CONNECTION_MUTATION, + ): + return _command_set_error(_CONNECTION_MUTATION_RATE_ERROR) + return None def _command_mutation_rate_limit(current_user): @@ -2030,8 +3015,12 @@ def _command_set_error(error, usages=None): code = 'not_found' elif error == _COMMAND_MUTATION_RATE_ERROR: code = 'rate_limited' + elif error == _CONNECTION_MUTATION_RATE_ERROR: + code = 'rate_limited' elif error and error.startswith('Command storage quota exceeded:'): code = 'quota_exceeded' + elif error and error.startswith('Connection storage quota exceeded:'): + code = 'quota_exceeded' elif error and 'unreadable' in error: code = 'storage_error' else: @@ -2184,11 +3173,10 @@ def handle_convert_legacy_command_set(data, current_user=None): payload = { 'success': True, 'command_set': command_set, - 'profile': updated_profile, + 'profile': _public_profile(current_user, updated_profile), } emit('command_set_converted', payload) handle_list_command_sets(current_user=current_user) - handle_list_profiles(current_user=current_user) return payload @socketio.on('save_session_name') @@ -2367,7 +3355,7 @@ def emit_unavailable(): def handle_prepare_transfer(data, current_user=None): """Issue only metadata for a later bounded HTTP transfer.""" payload = data if isinstance(data, dict) else {} - identity = _file_request_identity(payload) + identity = _file_request_identity(payload, current_user.id) try: if not _valid_file_request(identity): return { @@ -2424,7 +3412,13 @@ def handle_prepare_transfer(data, current_user=None): @socket_login_required def handle_cancel_transfer(data, current_user=None): """Cancel a prepared or streaming transfer owned by this user only.""" - transfer_id = data.get('transfer_id') if isinstance(data, dict) else None + payload = data if isinstance(data, dict) else {} + # Cancellation is a file-control event too: apply the same small-field and + # rolling-byte policy before using attacker-controlled identifiers. + _file_request_identity(payload, current_user.id) + transfer_id = payload.get('transfer_id') + if not payload.get('_file_control_valid') or not transfer_id: + return {'success': False, 'state': 'unavailable'} try: result = transfer_manager.cancel_with_result( transfer_id, current_user.id @@ -2449,7 +3443,7 @@ def handle_download_file_binary(data, current_user=None): """Handle binary file download (no base64 encoding).""" try: payload = data if isinstance(data, dict) else {} - identity = _file_request_identity(payload) + identity = _file_request_identity(payload, current_user.id) remote_path = payload.get('remote_path') for_preview = payload.get('for_preview', False) context = { @@ -3022,6 +4016,10 @@ def handle_quick_disconnect(data, current_user=None): return if result in {'closed', 'deferred'}: + file_service.discard_directory_snapshots( + user_id=current_user.id, + source_id=f'sftp-quick:{connection_id}', + ) emit('quick_disconnect_success', {'connection_id': connection_id}) else: emit('error', {'error': 'Connection not found'}) @@ -3036,7 +4034,8 @@ def handle_quick_disconnect(data, current_user=None): def handle_file_source_disconnect(data, current_user=None): """Close an owned ephemeral source without revealing foreign sources.""" payload = data if isinstance(data, dict) else {} - source_id = payload.get('source_id') + identity = _file_request_identity(payload, current_user.id) + source_id = identity.get('source_id') try: kind, handle_id = parse_source_id(source_id) except Exception: @@ -3054,6 +4053,10 @@ def handle_file_source_disconnect(data, current_user=None): result = 'unavailable' if result in {'closed', 'deferred'}: + file_service.discard_directory_snapshots( + user_id=current_user.id, + source_id=source_id, + ) emit( 'file_source_disconnect_success', {'source_id': source_id}, @@ -3067,7 +4070,7 @@ def handle_create_directory(data, current_user=None): """Create a directory on remote server.""" try: payload = data if isinstance(data, dict) else {} - identity = _file_request_identity(payload) + identity = _file_request_identity(payload, current_user.id) remote_path = payload.get('remote_path') context = { 'operation': 'create_directory', @@ -3129,7 +4132,7 @@ def handle_create_directory(data, current_user=None): def handle_rename_file(data, current_user=None): """Rename a file or directory on remote server.""" payload = data if isinstance(data, dict) else {} - identity = _file_request_identity(payload) + identity = _file_request_identity(payload, current_user.id) old_path = payload.get('old_path') new_path = payload.get('new_path') response_context = { @@ -3238,7 +4241,7 @@ def handle_delete_item(data, current_user=None): """Delete a file or directory (recursive) on remote server.""" try: payload = data if isinstance(data, dict) else {} - identity = _file_request_identity(payload) + identity = _file_request_identity(payload, current_user.id) path = payload.get('path') context = { 'operation': 'delete_item', @@ -3301,7 +4304,7 @@ def handle_get_home_directory(data, current_user=None): _t0 = _time.time() try: payload = data if isinstance(data, dict) else {} - identity = _file_request_identity(payload) + identity = _file_request_identity(payload, current_user.id) request_context = { 'operation': 'get_home_directory', **identity, @@ -3353,7 +4356,7 @@ def handle_check_exists(data, current_user=None): """Check if a file or directory exists on remote server.""" try: payload = data if isinstance(data, dict) else {} - identity = _file_request_identity(payload) + identity = _file_request_identity(payload, current_user.id) path = payload.get('path') context = { 'operation': 'check_exists', @@ -3396,7 +4399,7 @@ def handle_get_file_stat(data, current_user=None): """Get detailed file statistics.""" try: payload = data if isinstance(data, dict) else {} - identity = _file_request_identity(payload) + identity = _file_request_identity(payload, current_user.id) path = payload.get('path') context = { 'operation': 'get_file_stat', @@ -3445,7 +4448,7 @@ def handle_preview_file(data, current_user=None): """ try: payload = data if isinstance(data, dict) else {} - identity = _file_request_identity(payload) + identity = _file_request_identity(payload, current_user.id) path = payload.get('path') max_bytes = payload.get('max_bytes', 512000) offset = payload.get('offset', 0) @@ -3521,7 +4524,7 @@ def handle_open_file_for_edit(data, current_user=None): """Load a full text file for inline editing (no truncation, text only).""" try: payload = data if isinstance(data, dict) else {} - identity = _file_request_identity(payload) + identity = _file_request_identity(payload, current_user.id) path = payload.get('path') context = { 'operation': 'open_file_for_edit', @@ -3576,7 +4579,16 @@ def handle_save_file(data, current_user=None): """Save revision-bound editor content through its file source backend.""" try: payload = data if isinstance(data, dict) else {} - identity = _file_request_identity(payload) + editor_budget_exempt = _consume_editor_retry_challenge( + payload, + current_user.id, + ) + identity = _file_request_identity( + payload, + current_user.id, + allow_editor_content=True, + editor_budget_exempt=editor_budget_exempt, + ) path = payload.get('path') content = payload.get('content') encoding = payload.get('encoding', 'utf-8') @@ -3663,6 +4675,16 @@ def handle_save_file(data, current_user=None): failure['revision'] = outcome.revision if outcome.recovery_leaves: failure['recovery_leaves'] = list(outcome.recovery_leaves) + if ( + outcome.code == 'SMB_RECOVERABLE_REPLACE_REQUIRED' + and replace_strategy == 'atomic' + ): + challenge = _issue_editor_retry_challenge( + payload, + current_user.id, + ) + if challenge is not None: + failure['save_challenge'] = challenge emit('error', failure) return @@ -3706,7 +4728,7 @@ def handle_transfer_server_to_server(data, current_user=None): """ try: payload = data if isinstance(data, dict) else {} - identity = _file_request_identity(payload) + identity = _file_request_identity(payload, current_user.id) source_id = identity.get('source_id') requested_source_path = payload.get('source_path') destination_source_id = payload.get('destination_source_id') diff --git a/app/socket_protocol.py b/app/socket_protocol.py new file mode 100644 index 00000000..628e8d55 --- /dev/null +++ b/app/socket_protocol.py @@ -0,0 +1,7 @@ +"""Shared constants for the browser-to-server Socket.IO wire contract.""" + + +# This value is intentionally independent of the application release version. +# Increment it only when an incompatible Socket.IO payload contract is shipped. +SOCKET_WIRE_REVISION = 1 +SOCKET_PROTOCOL_MISMATCH_EVENT = 'socket_protocol_mismatch' diff --git a/app/ssh_manager.py b/app/ssh_manager.py index 183cb20f..e5a45144 100644 --- a/app/ssh_manager.py +++ b/app/ssh_manager.py @@ -174,6 +174,9 @@ def create_ssh_connection(host, port, username, password=None, key_path=None, ke return None, "User identity is required" user_id = host_key_store.user_id + if auth_type == 'tailscale' and proxy_jump_host: + return None, 'Tailscale SSH cannot be used with a jump host' + tailscale_target_authorized = False if auth_type == 'tailscale': if ( @@ -184,6 +187,7 @@ def create_ssh_connection(host, port, username, password=None, key_path=None, ke or not tailscale_authorization.matches( user_id, host, + port, username, ) ): @@ -209,24 +213,28 @@ def create_ssh_connection(host, port, username, password=None, key_path=None, ke host = canonicalize_hostname(host) proxy_jump_host = canonicalize_hostname(proxy_jump_host) try: - target = resolve_allowed_target( - host, - port, - allow_internal=( - not config.BLOCK_INTERNAL_SSH - or tailscale_target_authorized - ), + target = ( + tailscale_authorization.resolved_target + if tailscale_target_authorized + else resolve_allowed_target( + host, + port, + allow_internal=not config.BLOCK_INTERNAL_SSH, + ) ) channel_destination = (target.ip, target.port) host = target.hostname port = target.port except ValueError: remote_dns_allowed = ( - not config.BLOCK_INTERNAL_SSH - or proxy_jump_remote_dns_allowed( + not tailscale_target_authorized + and ( + not config.BLOCK_INTERNAL_SSH + or proxy_jump_remote_dns_allowed( host, config.PROXY_JUMP_REMOTE_DNS_ALLOWLIST, ) + ) ) if not remote_dns_allowed: return ( @@ -306,19 +314,27 @@ def create_ssh_connection(host, port, username, password=None, key_path=None, ke ) return None, "Jump host connection failed" else: - target = resolve_allowed_target( - host, - port, - allow_internal=( - not config.BLOCK_INTERNAL_SSH - or tailscale_target_authorized - ), + target = ( + tailscale_authorization.resolved_target + if tailscale_target_authorized + else resolve_allowed_target( + host, + port, + allow_internal=not config.BLOCK_INTERNAL_SSH, + ) ) host = target.hostname port = target.port - validated_socket = open_validated_socket( - target, config.SSH_CONNECT_TIMEOUT - ) + if tailscale_target_authorized: + validated_socket = open_validated_socket( + target, + config.SSH_CONNECT_TIMEOUT, + required_interface=config.TAILSCALE_SSH_INTERFACE, + ) + else: + validated_socket = open_validated_socket( + target, config.SSH_CONNECT_TIMEOUT + ) sock = validated_socket client = paramiko.SSHClient() diff --git a/app/ssh_output_flow.py b/app/ssh_output_flow.py index 3db4bccd..e182381d 100644 --- a/app/ssh_output_flow.py +++ b/app/ssh_output_flow.py @@ -332,7 +332,9 @@ def _disconnect_lagging_socket( # The disconnect still releases server resources if the advisory # marker cannot be delivered over an already-broken transport. pass - server.disconnect(socket_sid, namespace='/') + from .socket_events import disconnect_socket_transport + + disconnect_socket_transport(server, socket_sid) # Production disconnect handlers release first; keep this idempotent # fallback for test servers and disconnects without an application event. (flow_controller or ssh_output_flow).release_socket(socket_sid) diff --git a/app/startup_commands.py b/app/startup_commands.py index d800acfd..e71bf049 100644 --- a/app/startup_commands.py +++ b/app/startup_commands.py @@ -2,6 +2,24 @@ MAX_STARTUP_COMMANDS_LENGTH = 4096 +MAX_STARTUP_COMMANDS_UTF8_BYTES = MAX_STARTUP_COMMANDS_LENGTH * 4 + + +def validate_command_parameters(value): + """Bound a parameter fragment before it can be concatenated.""" + if not isinstance(value, str): + return 'Command parameters must be a string' + if len(value) > MAX_STARTUP_COMMANDS_LENGTH: + return 'Command parameters must not exceed 4096 characters' + try: + encoded_size = len(value.encode('utf-8')) + except UnicodeEncodeError: + return 'Command parameters must be valid UTF-8' + if encoded_size > MAX_STARTUP_COMMANDS_UTF8_BYTES: + return 'Command parameters exceed the UTF-8 byte limit' + if '\x00' in value: + return 'Commands cannot contain NUL bytes' + return None def normalize_startup_commands(value): diff --git a/app/storage_migrations.py b/app/storage_migrations.py index cd3b00ec..89fa3aea 100644 --- a/app/storage_migrations.py +++ b/app/storage_migrations.py @@ -9,11 +9,15 @@ import uuid from .storage_errors import StorageCorruptionError -from .storage_utils import atomic_write_json, fsync_parent_directory +from .storage_utils import ( + atomic_write_bytes, + atomic_write_json, + fsync_parent_directory, +) CURRENT_STORAGE_VERSIONS = { - 'profiles': 2, + 'profiles': 3, 'command_sets': 2, 'jump_hosts': 2, 'keys': 2, @@ -54,6 +58,18 @@ def migrate_profiles_v1_to_v2(document): return result +def migrate_profiles_v2_to_v3(document): + """Remove response-only authorization state from persisted profiles.""" + result = deepcopy(document) + profiles = result.get('profiles') + if isinstance(profiles, list): + for profile in profiles: + if isinstance(profile, dict): + profile.pop('tailscale_authorized', None) + result['schema_version'] = 3 + return result + + def migrate_command_sets_v0_to_v1(document): return _version_document(document, 1) @@ -109,6 +125,7 @@ def migrate_smb_shares_v1_to_v2(document): } for store_name in CURRENT_STORAGE_VERSIONS } +_MIGRATIONS['profiles'][2] = migrate_profiles_v2_to_v3 def migrate_document(store_name: str, document: object) -> tuple[object, bool]: @@ -179,12 +196,19 @@ def migrate_file( store_name: str, validator: Callable[[object], bool] | None = None, default_factory: Callable[[], object] | None = None, + *, + persist_migration: bool = True, + pre_migration_check: Callable[[object], None] | None = None, + migration_payload_factory: Callable[[object], bytes | None] | None = None, ) -> object: - """Load and migrate one file, backing it up before the first write. + """Load and validate one file, optionally persisting its migration. A default is used only when the initial file open raises ``FileNotFoundError``. Any later disappearance or other filesystem error - fails closed. + fails closed. ``pre_migration_check`` runs after decoding but before the + migration copies or transforms the document. A payload factory may return + exact approved bytes or ``None`` to keep a safe migration in memory when no + persisted representation satisfies the caller's storage policy. """ path = Path(path) source_missing = False @@ -210,6 +234,9 @@ def migrate_file( except json.JSONDecodeError as exc: raise StorageCorruptionError(path, 'invalid JSON') from exc + if pre_migration_check is not None: + pre_migration_check(document) + try: migrated, changed = migrate_document(store_name, document) except ValueError as exc: @@ -221,9 +248,20 @@ def migrate_file( raise StorageCorruptionError(path, 'validation failed') from exc if not valid: raise StorageCorruptionError(path, 'validation failed') - if source_missing or not changed: + if source_missing or not changed or not persist_migration: return migrated + migration_payload = None + if migration_payload_factory is not None: + migration_payload = migration_payload_factory(migrated) + if migration_payload is None: + return migrated + if not isinstance(migration_payload, bytes): + raise TypeError('migration payload factory must return bytes or None') + backup_before_migration(path) - atomic_write_json(path, migrated) + if migration_payload_factory is None: + atomic_write_json(path, migrated) + else: + atomic_write_bytes(path, migration_payload) return migrated diff --git a/app/storage_utils.py b/app/storage_utils.py index 8007e591..4880cf88 100644 --- a/app/storage_utils.py +++ b/app/storage_utils.py @@ -20,6 +20,7 @@ T = TypeVar('T') +SAFE_REFERENCE_MAX_BYTES = 128 _locks = {} _locks_guard = threading.Lock() @@ -64,12 +65,16 @@ def __exit__(self, exc_type, exc_value, traceback): def safe_reference_name(value): - """Return bounded printable display text for cross-store references.""" + """Return printable display text within a fixed UTF-8 byte budget.""" value = value if isinstance(value, str) else '' - return ''.join( + sanitized = ''.join( character if character.isprintable() else '\ufffd' for character in value - )[:128] + ) + return sanitized.encode('utf-8')[:SAFE_REFERENCE_MAX_BYTES].decode( + 'utf-8', + errors='ignore', + ) def storage_lock(key): @@ -125,8 +130,12 @@ def load_json_migrated( store_name: str, default_factory: Callable[[], T], validator: Callable[[object], bool], + *, + persist_migration: bool = True, + pre_migration_check: Callable[[object], None] | None = None, + migration_payload_factory: Callable[[object], bytes | None] | None = None, ) -> T: - """Load a current document, migrating an existing legacy file in place. + """Load a current document, optionally migrating a legacy file in place. The caller must hold the store's ``storage_lock`` so backup, migration, and the active-file replace are part of the same serialized operation. @@ -139,6 +148,9 @@ def load_json_migrated( store_name, validator, default_factory=default_factory, + persist_migration=persist_migration, + pre_migration_check=pre_migration_check, + migration_payload_factory=migration_payload_factory, ) diff --git a/app/tailscale_ssh.py b/app/tailscale_ssh.py index de87e443..1aeba861 100644 --- a/app/tailscale_ssh.py +++ b/app/tailscale_ssh.py @@ -1,10 +1,17 @@ """Authorization policy for the optional shared-identity Tailscale SSH mode.""" from dataclasses import dataclass +import ipaddress +import socket +import struct import config -from .network_policy import canonicalize_hostname +from .network_policy import ( + ResolvedTarget, + canonicalize_hostname, + resolve_allowed_target, +) @dataclass(frozen=True) @@ -13,21 +20,156 @@ class TailscaleSSHAuthorization: user_id: int host: str + port: int remote_username: str + resolved_target: ResolvedTarget - def matches(self, user_id, host, remote_username): + def matches(self, user_id, host, port, remote_username): try: canonical_host = canonicalize_hostname(host) clean_user_id = int(user_id) + clean_port = int(port) except (TypeError, ValueError): return False return ( clean_user_id == self.user_id and canonical_host == self.host + and clean_port == self.port and str(remote_username or '').strip() == self.remote_username + and self.resolved_target.hostname == self.host + and self.resolved_target.port == self.port ) +def _allowed_target_pairs(): + allowed = set() + for target in config.TAILSCALE_SSH_ALLOWED_TARGETS: + try: + allowed.add(config.parse_tailscale_ssh_target(target)) + except (TypeError, ValueError): + # Homelab keeps legacy startup compatibility, but an invalid entry + # never grants authority and cannot disable a valid sibling. + continue + return allowed + + +_NLMSG_HEADER = struct.Struct('=IHHII') +_RTMSG = struct.Struct('=BBBBBBBBI') +_RTATTR_HEADER = struct.Struct('=HH') +_RTM_NEWROUTE = 24 +_RTM_GETROUTE = 26 +_NLMSG_ERROR = 2 +_NLMSG_DONE = 3 +_NLM_F_REQUEST = 1 +_RTA_DST = 1 +_RTA_OIF = 4 + + +def _align_netlink(length): + return (length + 3) & ~3 + + +def _netlink_attribute(kind, payload): + length = _RTATTR_HEADER.size + len(payload) + return ( + _RTATTR_HEADER.pack(length, kind) + + payload + + (b'\x00' * (_align_netlink(length) - length)) + ) + + +def _route_interface_for_ip(address, *, socket_factory=socket.socket): + """Ask the kernel FIB which interface an exact address will use. + + RTM_GETROUTE follows Linux policy-routing rules, including Tailscale's + table 52. The proc route files expose only the main table and therefore + cannot validate normal Tailscale routes. + """ + try: + target = ipaddress.ip_address(address) + family = socket.AF_INET if target.version == 4 else socket.AF_INET6 + sequence = 1 + route_request = _RTMSG.pack( + family, + target.max_prefixlen, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ) + _netlink_attribute(_RTA_DST, target.packed) + message = _NLMSG_HEADER.pack( + _NLMSG_HEADER.size + len(route_request), + _RTM_GETROUTE, + _NLM_F_REQUEST, + sequence, + 0, + ) + route_request + route_socket = socket_factory( + socket.AF_NETLINK, + socket.SOCK_RAW, + socket.NETLINK_ROUTE, + ) + try: + route_socket.settimeout(1.0) + route_socket.bind((0, 0)) + route_socket.sendto(message, (0, 0)) + while True: + response = route_socket.recv(65535) + offset = 0 + while offset + _NLMSG_HEADER.size <= len(response): + length, kind, _flags, reply_sequence, _pid = ( + _NLMSG_HEADER.unpack_from(response, offset) + ) + if ( + length < _NLMSG_HEADER.size + or offset + length > len(response) + ): + return None + payload = response[ + offset + _NLMSG_HEADER.size:offset + length + ] + if reply_sequence == sequence: + if kind == _NLMSG_ERROR: + return None + if kind == _NLMSG_DONE: + return None + if kind == _RTM_NEWROUTE and len(payload) >= _RTMSG.size: + attributes = payload[_RTMSG.size:] + attr_offset = 0 + while attr_offset + _RTATTR_HEADER.size <= len(attributes): + attr_length, attr_kind = _RTATTR_HEADER.unpack_from( + attributes, attr_offset + ) + if ( + attr_length < _RTATTR_HEADER.size + or attr_offset + attr_length > len(attributes) + ): + return None + attr_payload = attributes[ + attr_offset + _RTATTR_HEADER.size: + attr_offset + attr_length + ] + if attr_kind == _RTA_OIF and len(attr_payload) >= 4: + interface_index = struct.unpack_from( + '=I', attr_payload + )[0] + return socket.if_indextoname(interface_index) + attr_offset += _align_netlink(attr_length) + offset += _align_netlink(length) + finally: + route_socket.close() + except (AttributeError, OSError, TypeError, ValueError): + return None + + +def target_uses_tailscale_route(address): + """Require the pinned target to leave through the configured tailnet.""" + return _route_interface_for_ip(address) == config.TAILSCALE_SSH_INTERFACE + + def user_can_use_tailscale_ssh(user): """Return whether a WebSSH user may use the node's Tailscale identity.""" if not config.TAILSCALE_SSH_ENABLED or not user: @@ -38,24 +180,23 @@ def user_can_use_tailscale_ssh(user): ) -def validate_tailscale_ssh_access(user, host, remote_username): +def validate_tailscale_ssh_access(user, host, remote_username, port=22): """Return an error message when the shared Tailscale identity is denied.""" if not user_can_use_tailscale_ssh(user): return 'Tailscale SSH is not enabled for this account' - - try: - canonical_host = canonicalize_hostname(host) - except ValueError: + if not str(config.TAILSCALE_SSH_INTERFACE or '').strip(): return 'Tailscale SSH target is not allowed' try: - allowed_targets = { - canonicalize_hostname(target) - for target in config.TAILSCALE_SSH_ALLOWED_TARGETS - } + canonical_host = canonicalize_hostname(host) + clean_port = int(port) + if not 1 <= clean_port <= 65535: + raise ValueError except (TypeError, ValueError): return 'Tailscale SSH target is not allowed' - if allowed_targets and canonical_host not in allowed_targets: + + allowed_targets = _allowed_target_pairs() + if not allowed_targets or (canonical_host, clean_port) not in allowed_targets: return 'Tailscale SSH target is not allowed' clean_remote_username = str(remote_username or '').strip() @@ -66,20 +207,33 @@ def validate_tailscale_ssh_access(user, host, remote_username): return None -def authorize_tailscale_ssh_access(user, host, remote_username): +def authorize_tailscale_ssh_access(user, host, remote_username, port=22): """Return an exact internal authorization object or a safe error.""" - error = validate_tailscale_ssh_access(user, host, remote_username) + error = validate_tailscale_ssh_access( + user, host, remote_username, port=port + ) if error: return None, error try: user_id = int(getattr(user, 'id')) canonical_host = canonicalize_hostname(host) + clean_port = int(port) + resolved_target = resolve_allowed_target( + canonical_host, + clean_port, + allow_internal=True, + target_validator=lambda target: target_uses_tailscale_route( + target.ip + ), + ) except (TypeError, ValueError): - return None, 'Tailscale SSH is not enabled for this account' + return None, 'Tailscale SSH target is not allowed' return TailscaleSSHAuthorization( user_id=user_id, host=canonical_host, + port=clean_port, remote_username=str(remote_username or '').strip(), + resolved_target=resolved_target, ), None @@ -91,4 +245,5 @@ def profile_is_authorized_for_launch(user, profile): user, profile.get('host'), profile.get('username'), + port=profile.get('port', 22), ) is None diff --git a/app/transfer_errors.py b/app/transfer_errors.py index 409c6229..78348858 100644 --- a/app/transfer_errors.py +++ b/app/transfer_errors.py @@ -55,6 +55,13 @@ 'retryable': True, 'http_status': 404, }, + 'SOURCE_CHANGED': { + 'messages': frozenset({ + 'The source changed during the transfer. Try again.', + }), + 'retryable': True, + 'http_status': 409, + }, 'LIMIT_EXCEEDED': { 'messages': frozenset({'The transfer exceeds the configured limit.'}), 'retryable': False, diff --git a/app/transfer_routes.py b/app/transfer_routes.py index 3d63403d..42ca4441 100644 --- a/app/transfer_routes.py +++ b/app/transfer_routes.py @@ -610,6 +610,29 @@ def _remote_zip_path(sftp, ssh_client, remote_path, cancel_event=None): return archive_path, size +def _create_private_temporary_archive(temp_directory): + temporary = tempfile.NamedTemporaryFile( + suffix='.zip', + delete=False, + dir=temp_directory, + ) + archive_path = Path(temporary.name) + try: + temporary.close() + archive_path.chmod(0o600) + except BaseException: + try: + temporary.close() + except BaseException: + pass + try: + archive_path.unlink(missing_ok=True) + except BaseException: + pass + raise + return archive_path + + def _build_backend_zip_to_disk( source, remote_folder, @@ -619,32 +642,33 @@ def _build_backend_zip_to_disk( max_bytes, chunk_size, temp_dir, + expected_root_identities=None, ): """Build a bounded local ZIP through only the FileBackend contract.""" - temp_directory = Path(temp_dir) - temp_directory.mkdir(mode=0o700, parents=True, exist_ok=True) - temporary = tempfile.NamedTemporaryFile( - suffix='.zip', - delete=False, - dir=temp_directory, - ) - archive_path = Path(temporary.name) - temporary.close() - archive_path.chmod(0o600) budget = TransferBudget( max_bytes=max_bytes, max_members=config.MAX_TRANSFER_MEMBERS, ) root = remote_folder.rstrip('/') + temp_directory = Path(temp_dir) + temp_directory.mkdir(mode=0o700, parents=True, exist_ok=True) + archive_path = _create_private_temporary_archive(temp_directory) try: + iterator_kwargs = { + 'budget': budget, + 'cancel_event': cancel_event, + 'follow_links': False, + 'io_lane': 'transfer', + } + if expected_root_identities is not None: + iterator_kwargs['_expected_identities'] = ( + expected_root_identities + ) entries = list(source.backend.iter_tree( source, remote_folder, - budget=budget, - cancel_event=cancel_event, - follow_links=False, - io_lane='transfer', + **iterator_kwargs, )) if any(entry.get('is_symlink') for entry in entries): raise RemoteTransferError('Reparse points are not supported') @@ -682,8 +706,14 @@ def _build_backend_zip_to_disk( if entry.get('is_dir'): archive.writestr(archive_name.rstrip('/') + '/', b'') continue + reader_kwargs = {'io_lane': 'transfer'} + identity_chain = entry.get('_smb_identity_chain') + if identity_chain is not None: + reader_kwargs['_expected_identities'] = identity_chain with source.backend.open_reader( - source, entry_path, io_lane='transfer' + source, + entry_path, + **reader_kwargs, ) as lease: if not isinstance(lease, FileReaderLease): raise RemoteTransferError('Source reader unavailable') @@ -715,8 +745,11 @@ def _build_backend_zip_to_disk( 'Archive exceeds transfer size limit' ) return archive_path - except Exception: - archive_path.unlink(missing_ok=True) + except BaseException: + try: + archive_path.unlink(missing_ok=True) + except BaseException: + pass raise @@ -799,6 +832,9 @@ def cleanup_resources(): max_bytes=config.MAX_ZIP_DOWNLOAD_SIZE, chunk_size=TRANSFER_CHUNK_SIZE, temp_dir=config.TRANSFER_TEMP_DIR, + expected_root_identities=remote_stat.get( + '_smb_identity_chain' + ), ) archive_size = local_archive.stat().st_size except Exception: diff --git a/app/user_lifecycle.py b/app/user_lifecycle.py index 6c92dfa5..2402dfd4 100644 --- a/app/user_lifecycle.py +++ b/app/user_lifecycle.py @@ -50,9 +50,14 @@ def revoke_user_access(user_id, socketio_instance=None): server = getattr(socketio_instance, 'server', None) if server is not None: + from .socket_events import ( + disconnect_engineio_transport, + disconnect_socket_transport, + ) + for socket_sid in socket_sids: try: - server.disconnect(socket_sid, namespace='/') + disconnect_socket_transport(server, socket_sid) except Exception as exc: result['errors'].append(f'socket:{socket_sid}:{exc}') log_warning( @@ -62,6 +67,35 @@ def revoke_user_access(user_id, socketio_instance=None): error=str(exc), ) + engineio_server = getattr(server, 'eio', None) + engineio_capacity = getattr( + engineio_server, + '_webssh_socket_capacity', + None, + ) + engineio_sids = ( + engineio_capacity.sids_for_user(user_id) + if engineio_capacity is not None + else () + ) + for engineio_sid in engineio_sids: + try: + disconnect_engineio_transport( + server, + engineio_sid, + drain=False, + ) + except Exception as exc: + result['errors'].append( + f'engineio:{engineio_sid}:{exc}' + ) + log_warning( + 'Failed to retire revoked Engine.IO transport', + user_id=user_id, + sid=engineio_sid, + error=str(exc), + ) + with ssh_manager.sessions_lock: ssh_session_ids = [ session_id diff --git a/config.py b/config.py index f8c15a4f..b13dd708 100644 --- a/config.py +++ b/config.py @@ -169,6 +169,89 @@ def _csv_env(name): ) +def parse_tailscale_ssh_target(raw_value): + """Return one canonical ``(host, port)`` Tailscale SSH policy entry.""" + if not isinstance(raw_value, str): + raise ValueError('Tailscale SSH target must be text') + value = raw_value.strip() + if not value or len(value) > 261 or '%' in value: + raise ValueError('Invalid Tailscale SSH target') + + host = value + port = 22 + if value.startswith('['): + closing = value.find(']') + if closing < 0: + raise ValueError('Invalid bracketed Tailscale SSH target') + host = value[1:closing] + suffix = value[closing + 1:] + if suffix: + port_text = suffix[1:] if suffix.startswith(':') else '' + if ( + not port_text + or len(port_text) > 5 + or not port_text.isascii() + or not port_text.isdigit() + ): + raise ValueError('Invalid Tailscale SSH target port') + port = int(port_text) + try: + address = ipaddress.ip_address(host) + except ValueError as exc: + raise ValueError( + 'Bracketed Tailscale SSH targets must be IPv6 addresses' + ) from exc + if address.version != 6: + raise ValueError( + 'Bracketed Tailscale SSH targets must be IPv6 addresses' + ) + canonical_host = address.compressed + else: + if '[' in value or ']' in value: + raise ValueError('Invalid bracketed Tailscale SSH target') + if value.count(':') == 1: + possible_host, possible_port = value.rsplit(':', 1) + if ( + possible_port + and len(possible_port) <= 5 + and possible_port.isascii() + and possible_port.isdigit() + ): + host = possible_host + port = int(possible_port) + + host = host.rstrip('.') + try: + canonical_host = ipaddress.ip_address(host).compressed + except ValueError: + if ':' in host: + raise ValueError('Invalid Tailscale SSH target') + try: + canonical_host = host.encode('idna').decode('ascii').lower() + except UnicodeError as exc: + raise ValueError('Invalid Tailscale SSH target') from exc + if ( + not canonical_host + or len(canonical_host) > 253 + or any( + not label + or len(label) > 63 + or label.startswith('-') + or label.endswith('-') + or not all( + character.isalnum() or character == '-' + for character in label + ) + for label in canonical_host.split('.') + ) + ): + raise ValueError('Invalid Tailscale SSH target') + + if not 1 <= port <= 65535: + raise ValueError('Invalid Tailscale SSH target port') + return canonical_host, port + + LDAP_CONNECT_TIMEOUT = _bounded_int_env( 'LDAP_CONNECT_TIMEOUT', 5, 1, 15 ) @@ -223,6 +306,9 @@ def _csv_env(name): 'BACKUP_TEMP_DIR', Path(tempfile.gettempdir()) / 'webssh-backup-operations', )) +BACKUP_RECOVERY_DURABLE = ( + os.environ.get('BACKUP_RECOVERY_DURABLE', 'false').lower() == 'true' +) # Atomic, in-process resource quotas. Per-user defaults remain below their @@ -351,6 +437,15 @@ def _validate_quota_pair(kind, global_limit, per_user_limit, fair_slots): MAX_EDITOR_FILE_SIZE = _positive_int_env( 'MAX_EDITOR_FILE_SIZE', 5 * 1024 * 1024 ) +# Inline editor saves retain the per-file ceiling above and additionally share +# a rolling per-user byte budget. Four maximum-size saves per minute preserve +# ordinary edit/save workflows while bounding aggregate encode and backend I/O. +EDITOR_SAVE_BYTES_PER_MINUTE = _bounded_int_env( + 'EDITOR_SAVE_BYTES_PER_MINUTE', + 4 * MAX_EDITOR_FILE_SIZE, + MAX_EDITOR_FILE_SIZE, + 64 * MAX_EDITOR_FILE_SIZE, +) # Socket.IO now carries control events and bounded editor text only; bulk file # transfers use streaming HTTP routes. JSON can expand control characters to a # six-byte ``\uXXXX`` escape, so retain that worst-case expansion plus a small @@ -417,6 +512,80 @@ def _validate_quota_pair(kind, global_limit, per_user_limit, fair_slots): 16 * 1024 * 1024, ) +# Browser file-control messages share a transport with bounded editor content. +# Keep their identifiers and paths small enough that one authenticated account +# cannot turn the editor envelope into repeated response and log amplification. +FILE_CONTROL_MAX_PATH_BYTES = _bounded_int_env( + 'FILE_CONTROL_MAX_PATH_BYTES', 4096, 512, 16 * 1024 +) +REMOTE_FILENAME_MAX_BYTES = _bounded_int_env( + 'REMOTE_FILENAME_MAX_BYTES', 4096, 255, 16 * 1024 +) +REMOTE_LISTING_MAX_METADATA_BYTES = _bounded_int_env( + 'REMOTE_LISTING_MAX_METADATA_BYTES', + 4 * 1024 * 1024, + 64 * 1024, + 16 * 1024 * 1024, +) +REMOTE_LISTING_PAGE_SIZE = _bounded_int_env( + 'REMOTE_LISTING_PAGE_SIZE', 500, 50, 1000 +) +REMOTE_LISTING_SNAPSHOT_TTL_SECONDS = _bounded_int_env( + 'REMOTE_LISTING_SNAPSHOT_TTL_SECONDS', 60, 10, 300 +) +REMOTE_LISTING_SNAPSHOT_MAX_STATES = _bounded_int_env( + 'REMOTE_LISTING_SNAPSHOT_MAX_STATES', 8, 1, 64 +) +REMOTE_LISTING_SNAPSHOT_MAX_PER_USER = _bounded_int_env( + 'REMOTE_LISTING_SNAPSHOT_MAX_PER_USER', 4, 1, 8 +) +SFTP_MAX_PACKET_BYTES = _bounded_int_env( + 'SFTP_MAX_PACKET_BYTES', 1024 * 1024, 64 * 1024, 4 * 1024 * 1024 +) +SFTP_MAX_HANDLE_BYTES = _bounded_int_env( + 'SFTP_MAX_HANDLE_BYTES', 16 * 1024, 256, 64 * 1024 +) +FILE_CONTROL_BYTES_PER_MINUTE = _bounded_int_env( + 'FILE_CONTROL_BYTES_PER_MINUTE', + 2 * 1024 * 1024, + 64 * 1024, + 16 * 1024 * 1024, +) + +# Saved connection metadata lives beside the database and encrypted keys. +# Prospective limits still allow deletion and shrinking of legacy oversized +# stores so administrators can recover without hand-editing JSON files. +PROFILE_MAX_RECORDS = _bounded_int_env( + 'PROFILE_MAX_RECORDS', 500, 10, 2000 +) +JUMP_HOST_MAX_RECORDS = _bounded_int_env( + 'JUMP_HOST_MAX_RECORDS', 100, 10, 1000 +) +CONNECTION_STORE_MAX_BYTES = _bounded_int_env( + 'CONNECTION_STORE_MAX_BYTES', + 2 * 1024 * 1024, + 64 * 1024, + 8 * 1024 * 1024, +) +CONNECTION_CONFIG_MAX_BYTES = _bounded_int_env( + 'CONNECTION_CONFIG_MAX_BYTES', + 4 * 1024 * 1024, + CONNECTION_STORE_MAX_BYTES, + 16 * 1024 * 1024, +) +CONNECTION_STORE_RECOVERY_MAX_BYTES = _bounded_int_env( + 'CONNECTION_STORE_RECOVERY_MAX_BYTES', + max(16 * 1024 * 1024, CONNECTION_STORE_MAX_BYTES), + CONNECTION_STORE_MAX_BYTES, + 64 * 1024 * 1024, +) +CONNECTION_STORE_RECOVERY_MAX_RECORDS = _bounded_int_env( + 'CONNECTION_STORE_RECOVERY_MAX_RECORDS', + max(10_000, PROFILE_MAX_RECORDS, JUMP_HOST_MAX_RECORDS), + max(PROFILE_MAX_RECORDS, JUMP_HOST_MAX_RECORDS), + 100_000, +) + # Admin panel: comma-separated usernames granted admin on startup. ADMIN_USERS = [u.strip() for u in os.environ.get('ADMIN_USERS', '').split(',') if u.strip()] ADMIN_PANEL_ENABLED = os.environ.get('ADMIN_PANEL_ENABLED', 'True') == 'True' @@ -426,10 +595,11 @@ def _validate_quota_pair(kind, global_limit, per_user_limit, fair_slots): # unless the operator explicitly enables it and grants access to trusted users. TAILSCALE_SSH_ENABLED = os.environ.get('TAILSCALE_SSH_ENABLED', 'false').lower() == 'true' TAILSCALE_SSH_ALLOWED_WEBSSH_USERS = _csv_env('TAILSCALE_SSH_ALLOWED_WEBSSH_USERS') -TAILSCALE_SSH_ALLOWED_TARGETS = frozenset( - target.lower() for target in _csv_env('TAILSCALE_SSH_ALLOWED_TARGETS') -) +TAILSCALE_SSH_ALLOWED_TARGETS = _csv_env('TAILSCALE_SSH_ALLOWED_TARGETS') TAILSCALE_SSH_ALLOWED_REMOTE_USERS = _csv_env('TAILSCALE_SSH_ALLOWED_REMOTE_USERS') +TAILSCALE_SSH_INTERFACE = os.environ.get( + 'TAILSCALE_SSH_INTERFACE', 'tailscale0' +).strip() DEBUG = os.environ.get('DEBUG', 'False').lower() == 'true' @@ -588,6 +758,10 @@ def _validate_quota_pair(kind, global_limit, per_user_limit, fair_slots): 'COMMAND_MUTATION_RATELIMIT', '60 per minute', ) +RATELIMIT_CONNECTION_MUTATION = os.environ.get( + 'CONNECTION_MUTATION_RATELIMIT', + '60 per minute', +) REGISTRATION_ENABLED = os.environ.get( 'REGISTRATION_ENABLED', @@ -718,6 +892,17 @@ def _canonical_smb_target(raw_value): raise ValueError return canonical + tailscale_target_pairs = set() + malformed_tailscale_targets = False + if TAILSCALE_SSH_ENABLED: + for tailscale_target in TAILSCALE_SSH_ALLOWED_TARGETS: + try: + tailscale_target_pairs.add( + parse_tailscale_ssh_target(tailscale_target) + ) + except (TypeError, ValueError): + malformed_tailscale_targets = True + if SMB_ENABLED and not SMB_ALLOWED_TARGETS: raise RuntimeError( 'SECURITY ERROR: SMB_ALLOWED_TARGETS is required when ' @@ -981,6 +1166,26 @@ def _is_absolute_secret_path(value): ) if not BLOCK_INTERNAL_SSH: violations.append('BLOCK_INTERNAL_SSH must be true') + if TAILSCALE_SSH_ENABLED: + if not TAILSCALE_SSH_ALLOWED_TARGETS: + violations.append( + 'TAILSCALE_SSH_ALLOWED_TARGETS must contain exact host and ' + 'port entries when TAILSCALE_SSH_ENABLED is true' + ) + elif malformed_tailscale_targets: + violations.append( + 'TAILSCALE_SSH_ALLOWED_TARGETS contains a malformed target' + ) + elif not tailscale_target_pairs: + violations.append( + 'TAILSCALE_SSH_ALLOWED_TARGETS must contain at least one ' + 'valid target' + ) + if not TAILSCALE_SSH_INTERFACE: + violations.append( + 'TAILSCALE_SSH_INTERFACE must name the trusted Tailscale ' + 'network interface' + ) if not _trusted_proxies_explicit: violations.append( 'TRUSTED_PROXIES must be set explicitly, including 0 when ' @@ -1016,6 +1221,27 @@ def _is_absolute_secret_path(value): warnings.append( 'BLOCK_INTERNAL_SSH is disabled for the homelab profile' ) + if TAILSCALE_SSH_ENABLED: + if not TAILSCALE_SSH_ALLOWED_TARGETS: + warnings.append( + 'TAILSCALE_SSH_ALLOWED_TARGETS is empty; Tailscale SSH ' + 'connections fail closed in the homelab profile' + ) + elif malformed_tailscale_targets: + warnings.append( + 'TAILSCALE_SSH_ALLOWED_TARGETS contains malformed entries; ' + 'those entries are ignored in the homelab profile' + ) + if TAILSCALE_SSH_ALLOWED_TARGETS and not tailscale_target_pairs: + warnings.append( + 'TAILSCALE_SSH_ALLOWED_TARGETS contains no valid targets; ' + 'Tailscale SSH connections fail closed in the homelab profile' + ) + if not TAILSCALE_SSH_INTERFACE: + warnings.append( + 'TAILSCALE_SSH_INTERFACE is empty; Tailscale SSH connections ' + 'fail closed in the homelab profile' + ) return warnings diff --git a/docker-compose.yml b/docker-compose.yml index 40f8478f..bdeca467 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,8 +55,9 @@ services: # Configure narrow allowlists; see docs/tailscale-ssh.md. # - TAILSCALE_SSH_ENABLED=true # - TAILSCALE_SSH_ALLOWED_WEBSSH_USERS=operator - # - TAILSCALE_SSH_ALLOWED_TARGETS=tiny-server,100.64.0.10 + # - TAILSCALE_SSH_ALLOWED_TARGETS=tiny-server,100.64.0.10:2222 # - TAILSCALE_SSH_ALLOWED_REMOTE_USERS=root,ubuntu + # - TAILSCALE_SSH_INTERFACE=tailscale0 # Enabled by default: host-key management, recovery codes, audit export. # The false switches disable them; internal-target blocking is opt-in. @@ -76,7 +77,8 @@ services: # - BACKUP_UPLOAD_MAX_SIZE=1073741824 # - BACKUP_OPERATION_TIMEOUT=1800 # - BACKUP_DOWNLOAD_TTL=600 - # - BACKUP_TEMP_DIR=/tmp/webssh-backup-operations + - BACKUP_TEMP_DIR=/app/recovery + - BACKUP_RECOVERY_DURABLE=true # Optional feature: Passkeys. Set the exact public domain and origin. # - WEBAUTHN_ENABLED=true @@ -109,6 +111,7 @@ services: # - redis volumes: - webssh_data:/app/data + - webssh_recovery:/app/recovery healthcheck: test: ["CMD", "python", "-c", "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:' + os.getenv('PORT', '5000') + '/ready', timeout=2).read(1)"] interval: 30s @@ -125,4 +128,5 @@ services: volumes: webssh_data: + webssh_recovery: driver: local diff --git a/docs/tailscale-ssh.md b/docs/tailscale-ssh.md index 10996ed0..adc3d78a 100644 --- a/docs/tailscale-ssh.md +++ b/docs/tailscale-ssh.md @@ -17,7 +17,7 @@ Use this mode only in a trusted homelab or similarly controlled environment: 2. Limit that tag to TCP port 22 on only the required target tag or hosts. 3. Limit Tailscale SSH rules to the required remote OS usernames. 4. Keep WebSSH registration disabled or tightly controlled. -5. Configure WebSSH's optional target and remote-username allowlists as a +5. Configure WebSSH's mandatory target and optional remote-username allowlists as a second boundary. Tailnet ACL and SSH policy remain authoritative. Every authorized or denied Tailscale SSH attempt is written to the security @@ -31,15 +31,30 @@ Tailscale SSH is off by default. Enable it explicitly: ```env TAILSCALE_SSH_ENABLED=true TAILSCALE_SSH_ALLOWED_WEBSSH_USERS=operator -TAILSCALE_SSH_ALLOWED_TARGETS=tiny-server,100.64.0.10 +TAILSCALE_SSH_ALLOWED_TARGETS=tiny-server,100.64.0.10:2222 TAILSCALE_SSH_ALLOWED_REMOTE_USERS=root,ubuntu +TAILSCALE_SSH_INTERFACE=tailscale0 ``` Administrators are allowed when the feature is enabled. The `TAILSCALE_SSH_ALLOWED_WEBSSH_USERS` list grants access to additional WebSSH -usernames. Empty target or remote-user allowlists add no extra restriction; -they do not bypass Tailscale policy. Target matching is exact and -case-insensitive, while remote OS usernames are exact and case-sensitive. +usernames. The target allowlist is mandatory whenever the feature is enabled. +A bare hostname, IPv4 address, or IPv6 address means port 22. Use +`hostname:port`, `IPv4:port`, or `[IPv6]:port` for another port. Target matching +is exact and case-insensitive, while remote OS usernames are exact and +case-sensitive. A production deployment refuses to start when the enabled +feature has an empty or malformed target list or an empty interface. The +homelab profile emits security warnings, ignores individual malformed entries +so valid siblings still work, and fails every connection closed if no valid +target or interface remains. Values stay dormant while the feature is disabled. +After DNS resolution, WebSSH accepts only an +address whose kernel route uses `TAILSCALE_SSH_INTERFACE` (default +`tailscale0`), pins that address, and binds the connecting socket to the same +interface. A route change cannot silently move the connection to another +interface. + +Tailscale authentication cannot be combined with ProxyJump. The route and +interface proof applies only to a direct connection from the WebSSH host. ## Example tailnet policy @@ -145,18 +160,25 @@ services: - CORS_ORIGINS=* - ALLOW_CORS_WILDCARD=true - SESSION_COOKIE_SECURE=false + # Keep online-restore rollback state durable and separate from DATA_DIR. + - BACKUP_TEMP_DIR=/app/recovery + - BACKUP_RECOVERY_DURABLE=true # Keep disabled until an administrator has been created with the CLI. - TAILSCALE_SSH_ENABLED=false # Leave empty to allow only existing WebSSH administrators. - TAILSCALE_SSH_ALLOWED_WEBSSH_USERS= - TAILSCALE_SSH_ALLOWED_TARGETS=tiny-server - TAILSCALE_SSH_ALLOWED_REMOTE_USERS=root + - TAILSCALE_SSH_INTERFACE=tailscale0 volumes: - webssh_data:/app/data + - webssh_recovery:/app/recovery volumes: tailscale_state: webssh_data: + webssh_recovery: + driver: local ``` After the administrator bootstrap and allowlist configuration, enable the diff --git a/docs/wiki/Backup-Restore-and-Secret-Rotation.md b/docs/wiki/Backup-Restore-and-Secret-Rotation.md index 21a1d42a..0bca6cf9 100644 --- a/docs/wiki/Backup-Restore-and-Secret-Rotation.md +++ b/docs/wiki/Backup-Restore-and-Secret-Rotation.md @@ -29,6 +29,14 @@ Do not rely on the short-lived browser download as retention. Move the archive i Restore is deliberately disruptive and strongly confirmed: +Online restore is enabled only when `BACKUP_TEMP_DIR` is an absolute, private, +durable directory outside `DATA_DIR` and `BACKUP_RECOVERY_DURABLE=true` records +the operator's explicit acknowledgement. The rollback journal and emergency +archive live there, so the storage must survive both process termination and +container recreation. The repository Compose file provides the separate +`webssh_recovery` volume. A default system `/tmp` directory is suitable for +backup construction, but intentionally does not enable online restore. + 1. Upload the archive. 2. Let WebSSH validate format and safety limits. 3. Review the restore target and warnings. @@ -40,7 +48,7 @@ Restore is deliberately disruptive and strongly confirmed: 9. Persistent state is replaced and sessions are invalidated. 10. The process terminates intentionally so the service manager can start a clean runtime. -If an interruption occurs during replacement, the restore workflow attempts rollback from the emergency archive. Still take an independent backup before every restore and keep it outside the instance. +If an interruption occurs during replacement, the restore workflow attempts rollback from the emergency archive. On restart, the durable recovery directory remains available for diagnosis or recovery. Still take an independent backup before every restore and keep it outside the instance. ## CLI backup and restore diff --git a/docs/wiki/Configuration-Reference.md b/docs/wiki/Configuration-Reference.md index 801e3140..17046bcd 100644 --- a/docs/wiki/Configuration-Reference.md +++ b/docs/wiki/Configuration-Reference.md @@ -9,7 +9,7 @@ Start from the repository's `.env.example`. The tables below describe the operat | Variable | Purpose | Default or requirement | |---|---|---| | `SECRET_KEY` | Encrypts and signs security-sensitive state | Required for direct production starts. The container entrypoint can generate and persist it in the data volume. | -| `DATA_DIR` | SQLite database and per-user data root | `/app/data` in the container | +| `DATA_DIR` | Canonical SQLite, key, log, and generated-secret root | `/app/data` in the container; container overrides must be absolute and persistent | | `DEPLOYMENT_PROFILE` | Selects deployment safeguards | `homelab`; use `production` for Internet-facing deployments | | `DEBUG` | Flask debug mode | `False`; never enable in production | | `HOST` | Application bind address | `127.0.0.1` outside the container | @@ -98,6 +98,7 @@ Connection, transfer, background-work, and thread limits form one capacity model | `RATELIMIT_REAUTH` | `5 per minute` | | `SSH_CONNECT_RATELIMIT` | `10 per minute` | | `SSH_KEY_WRITE_RATELIMIT` | `30 per minute` | +| `CONNECTION_MUTATION_RATELIMIT` | `60 per minute` | ## SSH key and live-output limits @@ -133,11 +134,44 @@ SSH or persistent tmux session remains available for reconnect. | `MAX_PREVIEW_TAIL_LINES` | `10000` | | `MAX_SUPPORTED_FILE_SIZE` | `1073741824` (1 GiB) | | `SFTP_OPERATION_TIMEOUT` | `30` seconds | +| `SFTP_MAX_PACKET_BYTES` | `1048576` (1 MiB) | +| `SFTP_MAX_HANDLE_BYTES` | `16384` (16 KiB) | | `MAX_EDITOR_FILE_SIZE` | `5242880` (5 MiB) | +| `EDITOR_SAVE_BYTES_PER_MINUTE` | `20971520` (20 MiB per user) | | `TRANSFER_TEMP_DIR` | `DATA_DIR/tmp` | +| `FILE_CONTROL_MAX_PATH_BYTES` | `4096` bytes | +| `FILE_CONTROL_BYTES_PER_MINUTE` | `2097152` (2 MiB per user) | +| `REMOTE_FILENAME_MAX_BYTES` | `4096` bytes | +| `REMOTE_LISTING_MAX_METADATA_BYTES` | `4194304` (4 MiB) | +| `REMOTE_LISTING_PAGE_SIZE` | `500` entries | +| `REMOTE_LISTING_SNAPSHOT_TTL_SECONDS` | `60` seconds | +| `REMOTE_LISTING_SNAPSHOT_MAX_STATES` | `8` snapshots per process | +| `REMOTE_LISTING_SNAPSHOT_MAX_PER_USER` | `4` snapshots per user | +| `CONNECTION_STORE_RECOVERY_MAX_BYTES` | `16777216` (16 MiB) | +| `CONNECTION_STORE_RECOVERY_MAX_RECORDS` | `10000` | Bulk uploads and downloads are streamed over HTTP; Socket.IO carries control events and bounded editor content rather than entire files. Align proxy request-body and timeout limits with WebSSH when increasing an application limit. +SFTP directory responses are paged. Declared protocol packet and opaque handle +size, raw entry count (including `.` and `..`), filename, longname, aggregate +metadata, and file-control budgets are enforced before data is retained or +reflected. + +## Saved connection limits + +| Variable | Default | +|---|---:| +| `PROFILE_MAX_RECORDS` | `500` | +| `JUMP_HOST_MAX_RECORDS` | `100` | +| `CONNECTION_STORE_MAX_BYTES` | `2097152` (2 MiB per store) | +| `CONNECTION_CONFIG_MAX_BYTES` | `4194304` (4 MiB combined) | + +Legacy stores above a normal limit are quarantined from the browser UI. After +stopping every WebSSH process, `flask --app start connection-store list` and +`connection-store delete` provide a non-secret, bounded recovery path up to +the separate recovery ceilings shown above. Growth is rejected, and profile +and jump-host key references remain ownership checked. + ## Feature switches and tmux | Variable | Default or purpose | @@ -175,9 +209,14 @@ Bulk uploads and downloads are streamed over HTTP; Socket.IO carries control eve | `BACKUP_MAX_COMPRESSION_RATIO` | `200` | | `BACKUP_MAX_MANIFEST_SIZE` | `10485760` | | `BACKUP_TEMP_DIR` | System temporary directory under `webssh-backup-operations` | +| `BACKUP_RECOVERY_DURABLE` | `false`; must be `true` with durable external storage for online restore | Audit export scans at most 50,000 records and declares truncation in response metadata. Backup safety limits also cap archive member count, individual size, total size, compression ratio, and manifest size. +`BACKUP_TEMP_DIR` may remain ephemeral for backup creation. Online restore +additionally requires it to be absolute, private, outside `DATA_DIR`, and +durable across process/container replacement. + ## OIDC, GitHub, LDAP, Passkeys, and Tailscale Identity-provider variables are grouped in their dedicated pages: diff --git a/docs/wiki/Data-Storage-and-Persistence.md b/docs/wiki/Data-Storage-and-Persistence.md index 4dea1be4..0048ee79 100644 --- a/docs/wiki/Data-Storage-and-Persistence.md +++ b/docs/wiki/Data-Storage-and-Persistence.md @@ -2,6 +2,11 @@ All durable WebSSH state belongs under `DATA_DIR`. Mount that directory on persistent storage and back it up as one coordinated unit. +Container deployments require an absolute `DATA_DIR`. The entrypoint derives +the logs, SSH-key storage, and generated `secret_key` from this single +canonical root and refuses ambiguous legacy/new secret files. External secret +manager values remain outside this filesystem contract. + ## Directory layout Typical content includes: @@ -49,11 +54,14 @@ Profiles, commands, command sets, jump hosts, application settings, notes, encry JSON-backed state follows a full load-modify-save cycle while holding the shared storage lock. Writes use an atomic temporary-file replacement and filesystem synchronization. Corrupt JSON is not silently replaced with an empty default because doing so could turn a recoverable incident into permanent data loss. -Do not edit these files while WebSSH is running. Use the UI or supported APIs, or stop every process before a controlled offline repair. +Do not edit these files while WebSSH is running. Use the UI or supported APIs. +For an oversized legacy profile or jump-host store, stop every process and use +the `connection-store` Flask CLI so recovery remains inside the configured hard +byte and record ceilings; avoid hand-editing JSON. ## Additive migrations -Persisted JSON schemas are migrated additively. Current schema version 2 covers profiles, command sets, jump hosts, keys, settings, and application settings. Before changing a file, migration creates a private backup and writes the upgraded representation atomically. +Persisted JSON schemas are migrated additively. Profiles currently use schema version 3; command sets, jump hosts, keys, settings, application settings, and SMB shares use version 2. Before changing a file, migration creates a private backup and writes the upgraded representation atomically. The profile v3 migration removes transient Tailscale launch-authorization state from disk; that state is derived from the live server policy for each response. Database changes likewise preserve existing installations. Always take a verified native backup before upgrading across versions. @@ -74,3 +82,8 @@ Run the container or process under a dedicated identity and restrict `DATA_DIR` ## Backup rule Use WebSSH's native backup workflow for a live instance. For an offline filesystem backup, stop every WebSSH process first and capture the complete directory consistently. See [Backup, Restore, and Secret Rotation](Backup-Restore-and-Secret-Rotation). + +Online restore has a second persistence boundary: its private rollback journal +and emergency archive must use a durable absolute `BACKUP_TEMP_DIR` outside +`DATA_DIR`, with `BACKUP_RECOVERY_DURABLE=true`. Do not place that directory on +ephemeral container storage. diff --git a/docs/wiki/Docker-and-Docker-Compose.md b/docs/wiki/Docker-and-Docker-Compose.md index 55aebeec..31cb3a3a 100644 --- a/docs/wiki/Docker-and-Docker-Compose.md +++ b/docs/wiki/Docker-and-Docker-Compose.md @@ -21,6 +21,10 @@ volumes: Without this volume, users, profiles, keys, host trust, settings, backups, and the auto-generated application secret disappear with the container. +The repository Compose file also mounts `webssh_recovery` at `/app/recovery`. +This separate durable volume stores online-restore rollback state and must not +be nested below or shared with `/app/data`. + ## Base homelab deployment ```bash @@ -46,6 +50,13 @@ When `SECRET_KEY` is not supplied, the container entrypoint creates a strong secret and persists it at `DATA_DIR/secret_key`. This makes ordinary container recreation safe as long as the data volume is preserved. +If the container `DATA_DIR` is overridden, it must be absolute and that exact +directory must be mounted persistently. Logs, keys, and `secret_key` all move +together. If a legacy `/app/data/secret_key` exists, copy it to the new +`DATA_DIR` with mode `0600` before starting; startup refuses to silently create +a second encryption root. An explicitly supplied external `SECRET_KEY` still +takes precedence and is not copied into the data directory. + Provide an external secret only when the deployment has a deliberate secret management policy. A changed or lost `SECRET_KEY` invalidates browser sessions and prevents decryption of stored SSH keys. @@ -88,6 +99,13 @@ docker compose ps curl -fsS http://localhost:5000/ready ``` +If an earlier image already created the `webssh_recovery` volume with a +root-owned `/app/recovery` and startup now reports `Permission denied`, stop the +service before repairing it. An empty recovery volume may be removed and +recreated by Compose. Never remove a non-empty recovery volume during or after +an interrupted restore; preserve its contents and have an administrator change +the volume root to the image's `appuser` UID/GID with mode `0700` instead. + Record the currently deployed immutable image digest before replacing it: ```bash diff --git a/docs/wiki/GitHub-Authentication.md b/docs/wiki/GitHub-Authentication.md index 906411c6..0d1a8ec2 100644 --- a/docs/wiki/GitHub-Authentication.md +++ b/docs/wiki/GitHub-Authentication.md @@ -154,6 +154,15 @@ single-use state, a five-minute server-side state record, an exact callback, and bounded local continuations. WebSSH uses the immutable numeric GitHub user ID as the identity key; login, display name, and email are never durable keys. +GitHub authorization is accepted only for primary sign-in and account linking. +It cannot satisfy the fresh Step-up required for passkey enrollment, recovery +changes, provider configuration, restore, or other sensitive account actions, +because the callback does not provide WebSSH with signed recent-authentication +evidence. Use a local password, an existing passkey, TOTP, or an eligible +recovery method for those Step-up checks. The narrowly scoped initial-factor +bootstrap below is the only exception for a factorless account that WebSSH +itself auto-provisioned from GitHub. + Existing users connect GitHub from their Security settings after a WebSSH Step-up check and a successful GitHub authorization. A GitHub identity can be linked to only one WebSSH account, and each WebSSH account can have only one @@ -170,6 +179,31 @@ identity is rejected without matching by username or email. When enabled, WebSSH creates a new non-admin account and its identity binding atomically. GitHub metadata can never create or promote an administrator. +### Bootstrap the first independent factor + +An auto-provisioned account has no known local password. Before that user can +disconnect GitHub, a trusted host operator must authorize enrollment of the +first Passkey (or TOTP authenticator) with a short-lived, single-use code. For +example: + +```bash +docker compose exec webssh /app/entrypoint.sh flask --app start:app \ + issue-factor-bootstrap --username ACCOUNT --action passkey.enroll +``` + +Run this command only on a trusted host after verifying the requested WebSSH +account. The command refuses local, locked, MFA-enabled, or already-factorized +accounts. It prints a random code that expires after ten minutes and is bound +to the exact account, current authentication generation, and enrollment +action. Issuing a new code invalidates every earlier code for that account; +the code itself is never written to the database or audit log. + +The user then signs in with GitHub, opens **Security**, starts the matching +Passkey or authenticator enrollment, and enters the operator-issued code when +prompted. WebSSH consumes the code before issuing the normal session-bound, +single-use enrollment grant. The user should enroll and test a Passkey before +disconnecting GitHub because TOTP alone is not a primary sign-in replacement. + ## Organization policy With an empty allowlist, WebSSH performs no organization check. With one or diff --git a/docs/wiki/Profiles-Jump-Hosts-and-Commands.md b/docs/wiki/Profiles-Jump-Hosts-and-Commands.md index a5f73353..4803c75f 100644 --- a/docs/wiki/Profiles-Jump-Hosts-and-Commands.md +++ b/docs/wiki/Profiles-Jump-Hosts-and-Commands.md @@ -54,6 +54,29 @@ bastion. Select the jump host while editing a profile. Use least privilege on the bastion and target. The presence of a jump host does not bypass target network policy, session ownership, or host-key verification. +Saved profiles and jump hosts have per-field, record-count, and serialized-byte +budgets. The defaults permit 500 profiles, 100 jump hosts, 2 MiB per store, and +4 MiB combined. Mutations share a per-user rate limit. The normal UI +quarantines a legacy store above those limits so a large payload cannot be +expanded through Socket.IO. Stop every WebSSH process, then use the bounded +recovery CLI to discover non-secret record summaries and opaque selectors, then +delete exact records: + +```bash +python -m flask --app start connection-store list \ + --username USER --kind profiles --confirm-offline +python -m flask --app start connection-store delete \ + --username USER --kind profiles --selector SELECTOR --confirm-offline +``` + +Use `--kind jump-hosts` for the jump-host store. Recovery enforces the separate +byte and record ceilings before and after parsing, never prints startup +commands or unknown fields, and retains jump-host reference protection. The +opaque selector binds one record's current ordinal and content, so repeated or +truncated display IDs cannot delete the wrong record; list again after every +successful deletion. No mutation may grow an oversized store. Stored key +references must belong to the same WebSSH account. + For bastion-only DNS names that cannot resolve locally, configure an exact `PROXY_JUMP_REMOTE_DNS_ALLOWLIST`. Do not use wildcards. diff --git a/docs/wiki/Tailscale-SSH.md b/docs/wiki/Tailscale-SSH.md index 9c23c14c..c4861647 100644 --- a/docs/wiki/Tailscale-SSH.md +++ b/docs/wiki/Tailscale-SSH.md @@ -11,7 +11,7 @@ Use Tailscale SSH only when you have: - a dedicated Tailscale tag for the WebSSH node; - narrow tailnet ACL and SSH rules; - trusted WebSSH administrators or an explicit non-admin allowlist; -- exact target allowlists where practical; +- a mandatory exact target-and-port allowlist; - exact remote operating-system username allowlists; - a tested local WebSSH administrator and recovery path; - persistent Tailscale node state. @@ -24,14 +24,28 @@ remote-user, and tailnet policy. ```bash TAILSCALE_SSH_ENABLED=true TAILSCALE_SSH_ALLOWED_WEBSSH_USERS=operator -TAILSCALE_SSH_ALLOWED_TARGETS=tiny-server,100.64.0.10 +TAILSCALE_SSH_ALLOWED_TARGETS=tiny-server,100.64.0.10:2222 TAILSCALE_SSH_ALLOWED_REMOTE_USERS=root,ubuntu +TAILSCALE_SSH_INTERFACE=tailscale0 ``` Administrators are authorized by role when the feature is enabled. The user -allowlist adds specifically trusted non-admin WebSSH accounts. Empty target or -remote-user lists remove that extra application-level restriction, so define -them for a shared multi-user deployment. +allowlist adds specifically trusted non-admin WebSSH accounts. The target list +is required when the feature is enabled. A bare hostname, IPv4 address, or IPv6 +address means port 22; use `hostname:port`, `IPv4:port`, or `[IPv6]:port` for +another port. Production refuses to start with an empty or malformed enabled +target list or an empty interface. Homelab emits security warnings, ignores +individual malformed entries so valid siblings still work, and fails every +connection closed if no valid target or interface remains. Dormant values are +tolerated while the feature is disabled. WebSSH resolves once, accepts only an +address whose kernel route uses `TAILSCALE_SSH_INTERFACE` (default +`tailscale0`), pins that address, and binds the connecting socket to the same +interface. A route change cannot silently move the connection to another +interface. An empty remote-user list still delegates that dimension to +tailnet SSH policy. + +Tailscale authentication cannot be combined with ProxyJump. The route and +interface proof applies only to a direct connection from the WebSSH host. ## Tailnet policy concept @@ -106,16 +120,22 @@ services: - CORS_ORIGINS=* - ALLOW_CORS_WILDCARD=true - SESSION_COOKIE_SECURE=false + - BACKUP_TEMP_DIR=/app/recovery + - BACKUP_RECOVERY_DURABLE=true - TAILSCALE_SSH_ENABLED=false - TAILSCALE_SSH_ALLOWED_WEBSSH_USERS= - TAILSCALE_SSH_ALLOWED_TARGETS=tiny-server - TAILSCALE_SSH_ALLOWED_REMOTE_USERS=root + - TAILSCALE_SSH_INTERFACE=tailscale0 volumes: - webssh_data:/app/data + - webssh_recovery:/app/recovery volumes: tailscale_state: webssh_data: + webssh_recovery: + driver: local ``` The example starts with Tailscale SSH disabled. diff --git a/docs/wiki/Terminal-and-Persistent-tmux-Sessions.md b/docs/wiki/Terminal-and-Persistent-tmux-Sessions.md index 2b8e8100..68419692 100644 --- a/docs/wiki/Terminal-and-Persistent-tmux-Sessions.md +++ b/docs/wiki/Terminal-and-Persistent-tmux-Sessions.md @@ -31,6 +31,10 @@ requires a fresh **Copy** click before the remote text reaches the browser clipboard. Replayed output and ordinary non-tmux SSH sessions cannot request a clipboard write. Browser clipboard permissions still apply. +Remote OSC 52 clipboard requests are limited to 128 KiB of decoded UTF-8 text. +Oversized OSC/DCS control strings are discarded by the terminal parser before +they can accumulate unbounded browser memory. + ## Broadcast input Broadcast mode sends the same input to every open SSH session. Treat it as a diff --git a/docs/wiki/Upgrading-Rollback-and-FAQ.md b/docs/wiki/Upgrading-Rollback-and-FAQ.md index 77be9913..0799f94a 100644 --- a/docs/wiki/Upgrading-Rollback-and-FAQ.md +++ b/docs/wiki/Upgrading-Rollback-and-FAQ.md @@ -59,6 +59,14 @@ Use all Compose files from the same release or commit. - Optional identity providers work. - Logs show no migration, maintenance, or permission error. +Browser tabs opened before an upgrade may still run an incompatible Socket.IO +client. WebSSH rejects the mismatch before restoring or registering runtime +sessions. Clients that support the wire-revision check reload once and then +show a persistent manual reload action instead of retrying in a loop. A tab +from an older release cannot interpret the structured refusal and may show +only that it was disconnected; reload that tab manually. Clear an intervening +proxy or browser cache if a current tab still reports a mismatch. + ## Image-only rollback If the new runtime fails but persistent data is intact, stop the candidate and @@ -66,6 +74,9 @@ start the previously recorded immutable image against the same `/app/data` volume. Do not restore or rewrite data merely to roll back the image. After rollback, verify readiness, login, stored keys, terminal access, and SFTP. +Tabs from a wire-revision-aware newer image detect that the older server omits +or reports a different revision and reload once to fetch that server's bundle. +Reload manually if a tab remains disconnected after the rollback. If the newer application migrated data beyond the older version's supported schema, image-only rollback may be blocked; consult release notes and the native backup compatibility result before forcing any change. diff --git a/entrypoint.sh b/entrypoint.sh index 5ed6783a..0f1539bf 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,16 +1,32 @@ #!/bin/bash set -euo pipefail +# Resolve one canonical persistent root for application state and the +# encryption root. Relative paths are intentionally rejected because backup, +# restore, and secret rotation all treat DATA_DIR as an absolute trust boundary. +DATA_DIR="${DATA_DIR:-/app/data}" +case "$DATA_DIR" in + /*) ;; + *) + echo "ERROR: DATA_DIR must be an absolute path." >&2 + exit 1 + ;; +esac +DATA_DIR="$(python -c 'import os, sys; print(os.path.realpath(sys.argv[1]))' "$DATA_DIR")" +export DATA_DIR + # Data directories -mkdir -p /app/data/logs /app/data/keys -chmod 700 /app/data/logs /app/data/keys +mkdir -p "$DATA_DIR/logs" "$DATA_DIR/keys" +chmod 700 "$DATA_DIR" "$DATA_DIR/logs" "$DATA_DIR/keys" # SECRET_KEY resolution order: # 1) SECRET_KEY environment variable (explicit; wins, e.g. external secrets) -# 2) persisted file under the data dir (survives restarts if /app/data is a volume) +# 2) persisted file under DATA_DIR (survives restarts when DATA_DIR is a volume) # 3) auto-generated and persisted (zero-config first run) # Known placeholders (e.g. the compose template) are treated as "not set". -SECRET_KEY_FILE="/app/data/secret_key" +SECRET_KEY_FILE="$DATA_DIR/secret_key" +LEGACY_SECRET_KEY_FILE="/app/data/secret_key" + _sk="${SECRET_KEY:-}" case "$(printf '%s' "$_sk" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" in ""|""|"changeme"|"secret"|"your-secret-key") @@ -19,6 +35,17 @@ case "$(printf '%s' "$_sk" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" in esac if [ -z "$_sk" ]; then + if [ "$SECRET_KEY_FILE" != "$LEGACY_SECRET_KEY_FILE" ] && [ -f "$LEGACY_SECRET_KEY_FILE" ]; then + if [ ! -f "$SECRET_KEY_FILE" ]; then + echo "ERROR: a legacy secret exists at $LEGACY_SECRET_KEY_FILE but DATA_DIR is $DATA_DIR." >&2 + echo "Copy that file to $SECRET_KEY_FILE with mode 600 before starting WebSSH." >&2 + exit 1 + fi + if ! cmp -s "$LEGACY_SECRET_KEY_FILE" "$SECRET_KEY_FILE"; then + echo "ERROR: conflicting secret_key files exist under /app/data and DATA_DIR." >&2 + exit 1 + fi + fi if [ -f "$SECRET_KEY_FILE" ]; then _sk="$(cat "$SECRET_KEY_FILE")" echo "Loaded persisted SECRET_KEY from $SECRET_KEY_FILE" @@ -26,9 +53,9 @@ if [ -z "$_sk" ]; then _sk="$(python -c 'import secrets; print(secrets.token_hex(32))')" if (umask 077; printf '%s\n' "$_sk" > "$SECRET_KEY_FILE"); then echo "Generated a new SECRET_KEY and persisted it to $SECRET_KEY_FILE" - echo " Keep /app/data on a volume so it survives container re-creation." + echo " Keep $DATA_DIR on a volume so it survives container re-creation." else - echo "ERROR: could not write $SECRET_KEY_FILE -- mount a writable volume on /app/data." >&2 + echo "ERROR: could not write $SECRET_KEY_FILE -- mount a writable volume on $DATA_DIR." >&2 exit 1 fi fi diff --git a/scripts/vendor.js b/scripts/vendor.js index c1eeff11..f7abfeca 100644 --- a/scripts/vendor.js +++ b/scripts/vendor.js @@ -34,6 +34,18 @@ const files = [ function expectedContents(srcPath, dest) { const source = fs.readFileSync(srcPath); + if (dest === 'xterm/xterm.js') { + const parserLimit = 't.PAYLOAD_LIMIT=1e7'; + const boundedParserLimit = 't.PAYLOAD_LIMIT=2e5'; + const bundle = source.toString('utf8'); + const matches = bundle.split(parserLimit).length - 1; + if (matches !== 1) { + throw new Error( + 'xterm parser limit changed; review the OSC/DCS memory-bound patch.' + ); + } + return Buffer.from(bundle.replace(parserLimit, boundedParserLimit), 'utf8'); + } if (dest !== 'socketio/socket.io.min.js') return source; const vulnerableDecoder = 'if(o!=Number(o)||"-"!==t.charAt(i))throw new Error("Illegal attachments");r.attachments=Number(o)'; diff --git a/start.py b/start.py index ed17d408..060ebf41 100644 --- a/start.py +++ b/start.py @@ -2,81 +2,13 @@ warnings.filterwarnings('ignore', message='.*TripleDES.*') import os -import sys - - -_MAINTENANCE_COMMANDS = frozenset({ - 'backup', - 'create-admin', - 'rotate-secret-key', -}) -_FLASK_OPTIONS_WITH_VALUES = frozenset({ - '--app', - '-A', - '--env-file', - '-e', -}) - - -def _is_flask_cli_process(program_name=None, main_module_name=None): - if program_name is None: - program_name = sys.argv[0] - if main_module_name is None: - main_module = sys.modules.get('__main__') - main_module_spec = getattr(main_module, '__spec__', None) - main_module_name = getattr(main_module_spec, 'name', None) - - executable_name = os.path.splitext(os.path.basename(program_name))[0].lower() - return executable_name == 'flask' or main_module_name == 'flask.__main__' - - -def _flask_top_level_command(arguments): - skip_next = False - for index, argument in enumerate(arguments): - if skip_next: - skip_next = False - continue - if argument == '--': - return arguments[index + 1] if index + 1 < len(arguments) else None - if argument in _FLASK_OPTIONS_WITH_VALUES: - skip_next = True - continue - if any( - argument.startswith(f'{option}=') - for option in _FLASK_OPTIONS_WITH_VALUES - if option.startswith('--') - ): - continue - if argument.startswith('-A') and argument != '-A': - continue - if argument.startswith('-'): - continue - return argument - return None - - -def _is_maintenance_cli_invocation( - arguments=None, - program_name=None, - main_module_name=None, -): - arguments = sys.argv[1:] if arguments is None else arguments - if not _is_flask_cli_process(program_name, main_module_name): - return False - return _flask_top_level_command(arguments) in _MAINTENANCE_COMMANDS from app import create_app, socketio import config -if _is_maintenance_cli_invocation(): - app = create_app( - initialize_storage=False, - start_runtime=False, - initialize_oidc=False, - ) -else: - app = create_app() +app = create_app() +if not app.extensions.get('maintenance_cli_invocation', False): app.extensions['runtime_lifecycle'].install_process_shutdown_signals( config.RUNTIME_SHUTDOWN_GRACE_SECONDS ) diff --git a/static/js/app.js b/static/js/app.js index d7af18b2..0dafb4ca 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -11,11 +11,47 @@ const APP_ROOT = document.querySelector('meta[name="app-root"]')?.content || ''; window.APP_ROOT = APP_ROOT; - window.socket = io({ path: APP_ROOT + '/socket.io' }); + const socketProtocol = window.WebSSHSocketProtocol; + if (!socketProtocol) { + throw new Error('Socket protocol module is unavailable'); + } + window.socket = io({ + path: APP_ROOT + '/socket.io', + auth: { wire_revision: socketProtocol.WIRE_REVISION }, + }); const outputFlowReconnect = window.WebSSHSocketReconnect.create( window.socket ); + let socketProtocolReloadPending = false; + + function reloadForSocketProtocolMismatch() { + socketProtocolReloadPending = true; + showSocketProtocolReloadNotice(); + window.location.reload(); + } + + window.addEventListener('beforeunload', (event) => { + if (socketProtocolReloadPending) { + socketProtocolReloadPending = false; + return; + } + const activeSessions = Object.values(SessionManager.sessions).filter( + session => session.connected + ); + if (activeSessions.length > 0) { + const message = window.i18n + ? window.i18n.t( + 'session.closeWarning', + 'You have active SSH sessions. They will be closed.', + ) + : 'You have active SSH sessions. They will be closed.'; + event.preventDefault(); + event.returnValue = message; + return message; + } + }); + window.escapeHtml = function(text) { if (!text) return ''; const div = document.createElement('div'); @@ -65,7 +101,9 @@ try { presentation.action.onClick(); } finally { - dismiss(); + if (presentation.action.dismissOnClick !== false) { + dismiss(); + } } }); } else { @@ -76,12 +114,58 @@ } container.appendChild(notification); - const timeout = presentation.duration - || (notificationType === 'success' || notificationType === 'info' ? 2000 : 3000); - fadeTimer = setTimeout(dismiss, timeout); + if (presentation.persistent !== true) { + const timeout = presentation.duration + || (notificationType === 'success' || notificationType === 'info' ? 2000 : 3000); + fadeTimer = setTimeout(dismiss, timeout); + } return dismiss; }; + let socketProtocolReloadNoticeVisible = false; + + function showSocketProtocolReloadNotice() { + if (socketProtocolReloadNoticeVisible) return; + socketProtocolReloadNoticeVisible = true; + showNotification({ + message: window.i18n + ? i18n.t('connection.reloadRequired') + : 'WebSSH was updated. Reload this page to continue.', + type: 'error', + persistent: true, + onDismiss: () => { + socketProtocolReloadNoticeVisible = false; + }, + action: { + label: window.i18n + ? i18n.t('connection.reloadPage') + : 'Reload page', + onClick: reloadForSocketProtocolMismatch, + dismissOnClick: false, + }, + }); + } + + let socketProtocolStorage = null; + try { + socketProtocolStorage = window.sessionStorage; + } catch { + // Some privacy modes intentionally deny access to sessionStorage. + } + const socketProtocolMismatch = socketProtocol.createMismatchController({ + storage: socketProtocolStorage, + disconnect: () => window.socket?.disconnect(), + reload: reloadForSocketProtocolMismatch, + showManualReload: showSocketProtocolReloadNotice, + }); + socket.on(socketProtocol.MISMATCH_EVENT, data => { + socketProtocolMismatch.handleMismatch(data); + }); + socket.on('connect_error', error => { + if (error?.data?.code !== 'socket_protocol_mismatch') return; + socketProtocolMismatch.handleMismatch(error.data); + }); + window.ModalManager = { activeModal: null, previouslyFocused: new WeakMap(), @@ -699,7 +783,7 @@ const status = document.getElementById('editorStatus'); if (data.code === 'SMB_RECOVERABLE_REPLACE_REQUIRED') { if (this.recoverableReplaceSources.has(this.currentSourceId)) { - this.saveEdit('recoverable_swap'); + this.saveEdit('recoverable_swap', data.save_challenge); return true; } const prompt = window.i18n @@ -707,7 +791,7 @@ : 'This server cannot replace the file in one safe step. WebSSH can save it with a temporary recovery backup and restore the original if replacement fails. Use this method for this SMB connection until the page is reloaded?'; if (window.confirm(prompt)) { this.recoverableReplaceSources.add(this.currentSourceId); - this.saveEdit('recoverable_swap'); + this.saveEdit('recoverable_swap', data.save_challenge); } else if (status) { status.textContent = window.i18n ? i18n.t('editor.recoverableDeclined') @@ -924,7 +1008,7 @@ showNotification(msg, 'error'); }, - saveEdit(replaceStrategy = null) { + saveEdit(replaceStrategy = null, saveChallenge = null) { if (!this.editMode || !this.currentSourceId || !this.currentPath) return; const textarea = document.getElementById('editorContent'); if (!textarea) return; @@ -938,7 +1022,7 @@ ? 'recoverable_swap' : 'atomic' ); - socket.emit('save_file', { + const payload = { source_id: this.currentSourceId, path: this.currentPath, content: textarea.value, @@ -947,7 +1031,11 @@ expected_revision: this.editRevision, replace_strategy: selectedStrategy, request_id: this.currentSaveRequestId, - }); + }; + if (typeof saveChallenge === 'string' && saveChallenge) { + payload.save_challenge = saveChallenge; + } + socket.emit('save_file', payload); }, handleFileSaved(data) { @@ -1120,6 +1208,15 @@ socket.on('connected', (data) => { if (data && data.status === 'success' && window.socket) { + if (!socketProtocol.isCompatibleServer(data)) { + socketProtocolMismatch.handleMismatch({ + status: 'reload_required', + code: socketProtocol.MISMATCH_EVENT, + required_revision: data.wire_revision, + }); + return; + } + socketProtocolMismatch.markCompatible(); window.socket.emit('get_notepad'); } }); @@ -1240,11 +1337,13 @@ ProfileManager.setProfiles(data.profiles); }); - socket.on('profile_saved', () => { + socket.on('profile_saved', (data) => { + ProfileManager.upsertProfile(data?.profile); showNotification('Saved connection updated successfully', 'success'); }); - socket.on('profile_deleted', () => { + socket.on('profile_deleted', (data) => { + ProfileManager.removeProfile(data?.profile_id); showNotification('Saved connection deleted successfully', 'success'); }); @@ -1286,13 +1385,15 @@ if (window.JumpHostManager) window.JumpHostManager.setJumpHosts(data.jump_hosts); }); - socket.on('jump_host_saved', () => { + socket.on('jump_host_saved', (data) => { + window.JumpHostManager?.upsertJumpHost(data?.jump_host); showNotification(window.i18n ? i18n.t('jumphosts.savedOk') : 'Jump host saved', 'success'); document.getElementById('jumpHostForm')?.reset(); document.getElementById('jhKeyGroup')?.classList.add('hidden'); }); - socket.on('jump_host_deleted', () => { + socket.on('jump_host_deleted', (data) => { + window.JumpHostManager?.removeJumpHost(data?.jump_host_id); showNotification(window.i18n ? i18n.t('jumphosts.deleted') : 'Jump host deleted', 'success'); }); @@ -2466,6 +2567,7 @@ document.getElementById('logoutBtn').addEventListener('click', () => { const message = window.i18n ? i18n.t('auth.logoutConfirm') : 'Are you sure you want to logout? Active SSH sessions will be preserved.'; if (confirm(message)) { + SessionManager.clearScopedBrowserStorage(); const form = document.createElement('form'); form.method = 'POST'; form.action = APP_ROOT + '/logout'; @@ -2637,17 +2739,5 @@ window.ModalManager.close(document.getElementById('commandPaletteModal')); }); - window.addEventListener('beforeunload', (e) => { - const activeSessions = Object.values(SessionManager.sessions).filter(s => s.connected); - if (activeSessions.length > 0) { - const message = window.i18n - ? window.i18n.t('session.closeWarning', 'You have active SSH sessions. They will be closed.') - : 'You have active SSH sessions. They will be closed.'; - e.preventDefault(); - e.returnValue = message; - return message; - } - }); - }); })(); diff --git a/static/js/binary-transfer-client.js b/static/js/binary-transfer-client.js index d54bbace..331dca68 100644 --- a/static/js/binary-transfer-client.js +++ b/static/js/binary-transfer-client.js @@ -5,6 +5,7 @@ const TRANSFER_FAILURES = Object.freeze({ SHARE_UNAVAILABLE: 'The SMB share is unavailable.', TIMEOUT: 'The file operation timed out.', SOURCE_UNAVAILABLE: 'The file source is no longer available. Reconnect and try again.', + SOURCE_CHANGED: 'The source changed during the transfer. Try again.', LIMIT_EXCEEDED: 'The transfer exceeds the configured limit.', CANCELLED: 'The transfer was cancelled.', ATOMIC_REPLACE_UNAVAILABLE: 'Safe overwrite is unavailable for this destination.', diff --git a/static/js/command-set-manager.js b/static/js/command-set-manager.js index 662009b2..659f1038 100644 --- a/static/js/command-set-manager.js +++ b/static/js/command-set-manager.js @@ -541,6 +541,7 @@ window.CommandSetManager = { if (this.returnToConnection) this.selectForConnection(saved.id); } if (isLegacyConversion && typeof ProfileManager !== 'undefined') { + ProfileManager.upsertProfile(acknowledgement.profile); ProfileManager.clearLegacyCommands(); } this.load(); diff --git a/static/js/i18n-auth.js b/static/js/i18n-auth.js index 339d2734..a26bc326 100644 --- a/static/js/i18n-auth.js +++ b/static/js/i18n-auth.js @@ -110,6 +110,8 @@ const translations = { "security.authenticatorDefaultName": "Authenticator app", "security.authenticatorDeleted": "Authenticator app deleted", "security.authenticatorName": "Authenticator name", + "security.bootstrapCode": "Enrollment code", + "security.bootstrapCodeRequired": "Enrollment code is required.", "security.certificateAuthority": "Certificate authority", "security.chooseConfirmationMethod": "Choose how you want to confirm this security change.", "security.confirmAccountName": "Type your account name to confirm", @@ -122,6 +124,7 @@ const translations = { "security.confirmEnablePasskeyMfa": "Require a Passkey, authenticator app, or recovery code after every password or directory sign-in?", "security.confirmFactorChange": "Confirm this account security change.", "security.confirmRemoveRevocation": "Really remove the revocation for {host}?", + "security.confirmWithBootstrap": "Enter the one-time enrollment code issued by the WebSSH operator.", "security.confirmWithDirectory": "Confirm with the password you use for directory sign-in.", "security.confirmWithTotp": "Enter a current code from your authenticator app.", "security.connectGithub": "Connect GitHub", @@ -139,6 +142,7 @@ const translations = { "security.hostKeyTrusted": "Trusted key", "security.invalidTotpCode": "Enter a valid six-digit authenticator code.", "security.legacyPasskeyConfirm": "Create a replacement passkey? Test it before deleting the old passkey.", + "security.methodBootstrap": "Enrollment code", "security.methodGithub": "GitHub", "security.methodLdap": "Directory password", "security.methodOidc": "Identity provider", @@ -269,6 +273,8 @@ const translations = { "security.authenticatorDefaultName": "Ứng dụng xác thực", "security.authenticatorDeleted": "Đã xóa ứng dụng xác thực", "security.authenticatorName": "Tên ứng dụng xác thực", + "security.bootstrapCode": "Mã đăng ký", + "security.bootstrapCodeRequired": "Cần nhập mã đăng ký.", "security.certificateAuthority": "Tổ chức chứng thực", "security.chooseConfirmationMethod": "Chọn cách bạn muốn xác nhận thay đổi bảo mật này.", "security.confirmAccountName": "Nhập tên tài khoản để xác nhận", @@ -281,6 +287,7 @@ const translations = { "security.confirmEnablePasskeyMfa": "Yêu cầu Passkey, ứng dụng xác thực hoặc mã khôi phục sau mỗi lần đăng nhập bằng mật khẩu hoặc thư mục?", "security.confirmFactorChange": "Xác nhận thay đổi bảo mật cho tài khoản này.", "security.confirmRemoveRevocation": "Bạn có thực sự muốn gỡ trạng thái thu hồi cho {host} không?", + "security.confirmWithBootstrap": "Nhập mã đăng ký dùng một lần do quản trị viên WebSSH cấp.", "security.confirmWithDirectory": "Xác nhận bằng mật khẩu bạn dùng để đăng nhập thư mục.", "security.confirmWithTotp": "Nhập mã hiện tại từ ứng dụng xác thực.", "security.connectGithub": "Kết nối GitHub", @@ -298,6 +305,7 @@ const translations = { "security.hostKeyTrusted": "Khóa đáng tin cậy", "security.invalidTotpCode": "Nhập mã xác thực gồm sáu chữ số hợp lệ.", "security.legacyPasskeyConfirm": "Tạo passkey thay thế? Hãy kiểm tra nó trước khi xóa passkey cũ.", + "security.methodBootstrap": "Mã đăng ký", "security.methodGithub": "GitHub", "security.methodLdap": "Mật khẩu thư mục", "security.methodOidc": "Nhà cung cấp danh tính", @@ -428,6 +436,8 @@ const translations = { "security.authenticatorDefaultName": "Authenticator-App", "security.authenticatorDeleted": "Authenticator-App gelöscht", "security.authenticatorName": "Name des Authenticators", + "security.bootstrapCode": "Einrichtungscode", + "security.bootstrapCodeRequired": "Der Einrichtungscode ist erforderlich.", "security.certificateAuthority": "Zertifizierungsstelle", "security.chooseConfirmationMethod": "Wähle aus, wie du diese Sicherheitsänderung bestätigen möchtest.", "security.confirmAccountName": "Kontonamen zur Bestätigung eingeben", @@ -440,6 +450,7 @@ const translations = { "security.confirmEnablePasskeyMfa": "Nach jeder Passwort- oder Verzeichnis-Anmeldung einen Passkey, eine Authenticator-App oder einen Wiederherstellungscode verlangen?", "security.confirmFactorChange": "Bestätige diese Sicherheitsänderung für das Konto.", "security.confirmRemoveRevocation": "Die Sperre für {host} wirklich entfernen?", + "security.confirmWithBootstrap": "Gib den einmaligen Einrichtungscode ein, den der WebSSH-Betreiber ausgestellt hat.", "security.confirmWithDirectory": "Bestätige mit dem Kennwort, das du für die Verzeichnisanmeldung verwendest.", "security.confirmWithTotp": "Gib einen aktuellen Code aus deiner Authenticator-App ein.", "security.connectGithub": "GitHub verbinden", @@ -457,6 +468,7 @@ const translations = { "security.hostKeyTrusted": "Vertrauenswürdiger Schlüssel", "security.invalidTotpCode": "Gib einen gültigen sechsstelligen Authenticator-Code ein.", "security.legacyPasskeyConfirm": "Einen Ersatz-Passkey erstellen? Teste ihn, bevor du den alten Passkey löschst.", + "security.methodBootstrap": "Einrichtungscode", "security.methodGithub": "GitHub", "security.methodLdap": "Verzeichniskennwort", "security.methodOidc": "Identitätsanbieter", @@ -587,6 +599,8 @@ const translations = { "security.authenticatorDefaultName": "Application d’authentification", "security.authenticatorDeleted": "Application d’authentification supprimée", "security.authenticatorName": "Nom de l’authentificateur", + "security.bootstrapCode": "Code d’inscription", + "security.bootstrapCodeRequired": "Le code d’inscription est requis.", "security.certificateAuthority": "Autorité de certification", "security.chooseConfirmationMethod": "Choisissez comment confirmer cette modification de sécurité.", "security.confirmAccountName": "Saisissez le nom du compte pour confirmer", @@ -599,6 +613,7 @@ const translations = { "security.confirmEnablePasskeyMfa": "Exiger une clé d’accès, une application d’authentification ou un code de récupération après chaque connexion par mot de passe ou annuaire ?", "security.confirmFactorChange": "Confirmez cette modification de sécurité du compte.", "security.confirmRemoveRevocation": "Retirer la révocation pour {host} ?", + "security.confirmWithBootstrap": "Saisissez le code d’inscription à usage unique émis par l’opérateur WebSSH.", "security.confirmWithDirectory": "Confirmez avec le mot de passe utilisé pour la connexion à l’annuaire.", "security.confirmWithTotp": "Saisissez un code actuel de votre application d’authentification.", "security.connectGithub": "Connecter GitHub", @@ -616,6 +631,7 @@ const translations = { "security.hostKeyTrusted": "Clé approuvée", "security.invalidTotpCode": "Saisissez un code d’authentification valide à six chiffres.", "security.legacyPasskeyConfirm": "Créer une clé d’accès de remplacement ? Testez-la avant de supprimer l’ancienne clé.", + "security.methodBootstrap": "Code d’inscription", "security.methodGithub": "GitHub", "security.methodLdap": "Mot de passe de l’annuaire", "security.methodOidc": "Fournisseur d’identité", @@ -746,6 +762,8 @@ const translations = { "security.authenticatorDefaultName": "Aplicación de autenticación", "security.authenticatorDeleted": "Aplicación de autenticación eliminada", "security.authenticatorName": "Nombre del autenticador", + "security.bootstrapCode": "Código de inscripción", + "security.bootstrapCodeRequired": "Se requiere el código de inscripción.", "security.certificateAuthority": "Autoridad certificadora", "security.chooseConfirmationMethod": "Elige cómo quieres confirmar este cambio de seguridad.", "security.confirmAccountName": "Escribe el nombre de la cuenta para confirmar", @@ -758,6 +776,7 @@ const translations = { "security.confirmEnablePasskeyMfa": "¿Exigir una passkey, una aplicación de autenticación o un código de recuperación después de cada inicio con contraseña o directorio?", "security.confirmFactorChange": "Confirma este cambio de seguridad de la cuenta.", "security.confirmRemoveRevocation": "¿Quitar la revocación de {host}?", + "security.confirmWithBootstrap": "Introduce el código de inscripción de un solo uso emitido por el operador de WebSSH.", "security.confirmWithDirectory": "Confirma con la contraseña que utilizas para iniciar sesión en el directorio.", "security.confirmWithTotp": "Introduce un código actual de tu aplicación de autenticación.", "security.connectGithub": "Conectar GitHub", @@ -775,6 +794,7 @@ const translations = { "security.hostKeyTrusted": "Clave de confianza", "security.invalidTotpCode": "Introduce un código de autenticación válido de seis dígitos.", "security.legacyPasskeyConfirm": "¿Crear una passkey de reemplazo? Pruébala antes de eliminar la passkey antigua.", + "security.methodBootstrap": "Código de inscripción", "security.methodGithub": "GitHub", "security.methodLdap": "Contraseña del directorio", "security.methodOidc": "Proveedor de identidad", @@ -905,6 +925,8 @@ const translations = { "security.authenticatorDefaultName": "身份验证器应用", "security.authenticatorDeleted": "身份验证器应用已删除", "security.authenticatorName": "身份验证器名称", + "security.bootstrapCode": "注册代码", + "security.bootstrapCodeRequired": "请输入注册代码。", "security.certificateAuthority": "证书颁发机构", "security.chooseConfirmationMethod": "选择用于确认此安全更改的方式。", "security.confirmAccountName": "输入账户名以确认", @@ -917,6 +939,7 @@ const translations = { "security.confirmEnablePasskeyMfa": "每次使用密码或目录登录后,都要求通行密钥、验证器应用或恢复代码吗?", "security.confirmFactorChange": "确认此账户安全更改。", "security.confirmRemoveRevocation": "确定要移除 {host} 的吊销记录吗?", + "security.confirmWithBootstrap": "输入 WebSSH 管理员签发的一次性注册代码。", "security.confirmWithDirectory": "使用目录登录密码进行确认。", "security.confirmWithTotp": "输入身份验证器应用中的当前代码。", "security.connectGithub": "连接 GitHub", @@ -934,6 +957,7 @@ const translations = { "security.hostKeyTrusted": "受信任密钥", "security.invalidTotpCode": "请输入有效的六位身份验证器代码。", "security.legacyPasskeyConfirm": "创建替代通行密钥?删除旧通行密钥前请先测试新密钥。", + "security.methodBootstrap": "注册代码", "security.methodGithub": "GitHub", "security.methodLdap": "目录密码", "security.methodOidc": "身份提供商", diff --git a/static/js/i18n.js b/static/js/i18n.js index 53d8880a..c178a575 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -204,6 +204,8 @@ const translations = { 'connection.lostReconnecting': 'Connection lost. Reconnecting...', 'connection.lostReconnectingAttempt': 'Connection lost. Reconnecting... (attempt {attempt})', 'connection.disconnectedFromServer': 'Disconnected from server', + 'connection.reloadRequired': 'WebSSH was updated. Reload this page to continue.', + 'connection.reloadPage': 'Reload page', 'keys.manageKeys': 'Manage Keys', 'keys.manageSSHKeys': 'Manage SSH Keys', @@ -318,6 +320,7 @@ const translations = { 'smb.error.timeout': 'The SMB server did not respond in time.', 'smb.error.encryption': 'The server does not support the required SMB encryption.', 'smb.error.dialect': 'The server does not support SMB 3.1.1.', + 'smb.error.identityUnavailable': 'This SMB server cannot provide the stable file identities required for secure access.', 'smb.error.shutdown': 'The server is shutting down.', 'smb.error.invalid': 'Check the connection details and try again.', 'smb.error.connection': 'The SMB connection could not be established.', @@ -508,6 +511,9 @@ const translations = { 'security.authenticatorDeleted': 'Authenticator app deleted', 'security.confirmWithDirectory': 'Confirm with the password you use for directory sign-in.', 'security.confirmWithTotp': 'Enter a current code from your authenticator app.', + 'security.confirmWithBootstrap': 'Enter the one-time enrollment code issued by the WebSSH operator.', + 'security.bootstrapCode': 'Enrollment code', + 'security.bootstrapCodeRequired': 'Enrollment code is required.', 'security.directoryPassword': 'Directory password', 'security.methodLdap': 'Directory password', 'security.methodOidc': 'Identity provider', @@ -524,6 +530,7 @@ const translations = { 'security.githubDisconnected': 'GitHub disconnected', 'security.methodPasskey': 'Passkey', 'security.methodPassword': 'WebSSH password', + 'security.methodBootstrap': 'Enrollment code', 'security.methodTotp': 'Authenticator app', 'security.confirmSecurityAction': 'Confirm security action', 'security.confirmDisableMfa': 'Disable every MFA factor?', @@ -800,6 +807,7 @@ const translations = { 'transfer.error.SHARE_UNAVAILABLE': 'The SMB share is unavailable.', 'transfer.error.TIMEOUT': 'The file operation timed out.', 'transfer.error.SOURCE_UNAVAILABLE': 'The file source is no longer available. Reconnect and try again.', + 'transfer.error.SOURCE_CHANGED': 'The source changed during the transfer. Try again.', 'transfer.error.LIMIT_EXCEEDED': 'The transfer exceeds the configured limit.', 'transfer.limit.message': '{actual} exceeds the {limit} {kind} limit.', 'transfer.limit.kind.upload': 'upload', @@ -985,6 +993,7 @@ const translations = { 'fm.emptyDirectory': 'Empty directory', 'fm.selectSourceAbove': 'Select a source above', 'fm.loading': 'Loading...', + 'fm.loadMore': 'Load more', 'fm.connectionTimeout': 'Connection timeout - could not load directory', 'fm.items': 'items', 'fm.selected': 'selected', @@ -1476,6 +1485,8 @@ const translations = { 'connection.lostReconnecting': 'Mất kết nối. Đang kết nối lại...', 'connection.lostReconnectingAttempt': 'Mất kết nối. Đang kết nối lại... (lần thử {attempt})', 'connection.disconnectedFromServer': 'Đã ngắt kết nối với máy chủ', + 'connection.reloadRequired': 'WebSSH đã được cập nhật. Hãy tải lại trang này để tiếp tục.', + 'connection.reloadPage': 'Tải lại trang', 'keys.manageKeys': 'Quản lý khóa', 'keys.manageSSHKeys': 'Quản lý khóa SSH', @@ -1590,6 +1601,7 @@ const translations = { 'smb.error.timeout': 'Máy chủ SMB không phản hồi kịp thời.', 'smb.error.encryption': 'Máy chủ không hỗ trợ mã hóa SMB bắt buộc.', 'smb.error.dialect': 'Máy chủ không hỗ trợ SMB 3.1.1.', + 'smb.error.identityUnavailable': 'Máy chủ SMB này không thể cung cấp mã định danh tệp ổn định cần thiết để truy cập an toàn.', 'smb.error.shutdown': 'Máy chủ đang tắt.', 'smb.error.invalid': 'Kiểm tra thông tin kết nối rồi thử lại.', 'smb.error.connection': 'Không thể thiết lập kết nối SMB.', @@ -1743,6 +1755,9 @@ const translations = { 'security.authenticatorDeleted': 'Đã xóa ứng dụng xác thực', 'security.confirmWithDirectory': 'Xác nhận bằng mật khẩu bạn dùng để đăng nhập thư mục.', 'security.confirmWithTotp': 'Nhập mã hiện tại từ ứng dụng xác thực.', + 'security.confirmWithBootstrap': 'Nhập mã đăng ký dùng một lần do quản trị viên WebSSH cấp.', + 'security.bootstrapCode': 'Mã đăng ký', + 'security.bootstrapCodeRequired': 'Cần nhập mã đăng ký.', 'security.directoryPassword': 'Mật khẩu thư mục', 'security.methodLdap': 'Mật khẩu thư mục', 'security.methodOidc': 'Nhà cung cấp danh tính', @@ -1759,6 +1774,7 @@ const translations = { 'security.githubDisconnected': 'Đã ngắt kết nối GitHub', 'security.methodPasskey': 'Passkey', 'security.methodPassword': 'Mật khẩu WebSSH', + 'security.methodBootstrap': 'Mã đăng ký', 'security.methodTotp': 'Ứng dụng xác thực', 'security.confirmSecurityAction': 'Xác nhận thao tác bảo mật', 'security.confirmDisableMfa': 'Tắt tất cả các yếu tố MFA?', @@ -2035,6 +2051,7 @@ const translations = { 'transfer.error.SHARE_UNAVAILABLE': 'Phần chia sẻ SMB không khả dụng.', 'transfer.error.TIMEOUT': 'Thao tác tệp đã hết thời gian chờ.', 'transfer.error.SOURCE_UNAVAILABLE': 'Nguồn tệp không còn khả dụng. Hãy kết nối lại và thử lại.', + 'transfer.error.SOURCE_CHANGED': 'Nguồn đã thay đổi trong khi truyền. Hãy thử lại.', 'transfer.error.LIMIT_EXCEEDED': 'Quá trình truyền vượt quá giới hạn đã cấu hình.', 'transfer.limit.message': '{actual} vượt quá giới hạn {kind} là {limit}.', 'transfer.limit.kind.upload': 'tải lên', @@ -2257,6 +2274,7 @@ const translations = { 'fm.emptyDirectory': 'Thư mục trống', 'fm.selectSourceAbove': 'Chọn một nguồn ở trên', 'fm.loading': 'Đang tải...', + 'fm.loadMore': 'Tải thêm', 'fm.connectionTimeout': 'Hết thời gian chờ kết nối - không thể tải thư mục', 'fm.items': 'mục', 'fm.selected': 'đã chọn', @@ -2747,6 +2765,8 @@ const translations = { 'connection.lostReconnecting': 'Verbindung unterbrochen. Wiederverbindung läuft...', 'connection.lostReconnectingAttempt': 'Verbindung unterbrochen. Wiederverbindung läuft... (Versuch {attempt})', 'connection.disconnectedFromServer': 'Verbindung zum Server getrennt', + 'connection.reloadRequired': 'WebSSH wurde aktualisiert. Laden Sie diese Seite neu, um fortzufahren.', + 'connection.reloadPage': 'Seite neu laden', 'keys.manageKeys': 'Schlüssel verwalten', 'keys.manageSSHKeys': 'SSH-Schlüssel verwalten', @@ -2861,6 +2881,7 @@ const translations = { 'smb.error.timeout': 'Der SMB-Server hat nicht rechtzeitig geantwortet.', 'smb.error.encryption': 'Der Server unterstützt die erforderliche SMB-Verschlüsselung nicht.', 'smb.error.dialect': 'Der Server unterstützt SMB 3.1.1 nicht.', + 'smb.error.identityUnavailable': 'Dieser SMB-Server kann die für einen sicheren Zugriff erforderlichen stabilen Datei-IDs nicht bereitstellen.', 'smb.error.shutdown': 'Der Server wird heruntergefahren.', 'smb.error.invalid': 'Prüfen Sie die Verbindungsdaten und versuchen Sie es erneut.', 'smb.error.connection': 'Die SMB-Verbindung konnte nicht hergestellt werden.', @@ -3051,6 +3072,9 @@ const translations = { 'security.authenticatorDeleted': 'Authenticator-App gelöscht', 'security.confirmWithDirectory': 'Bestätige mit dem Kennwort, das du für die Verzeichnisanmeldung verwendest.', 'security.confirmWithTotp': 'Gib einen aktuellen Code aus deiner Authenticator-App ein.', + 'security.confirmWithBootstrap': 'Gib den einmaligen Einrichtungscode ein, den der WebSSH-Betreiber ausgestellt hat.', + 'security.bootstrapCode': 'Einrichtungscode', + 'security.bootstrapCodeRequired': 'Der Einrichtungscode ist erforderlich.', 'security.directoryPassword': 'Verzeichniskennwort', 'security.methodLdap': 'Verzeichniskennwort', 'security.methodOidc': 'Identitätsanbieter', @@ -3067,6 +3091,7 @@ const translations = { 'security.githubDisconnected': 'GitHub getrennt', 'security.methodPasskey': 'Passkey', 'security.methodPassword': 'WebSSH-Kennwort', + 'security.methodBootstrap': 'Einrichtungscode', 'security.methodTotp': 'Authenticator-App', 'security.confirmSecurityAction': 'Sicherheitsaktion bestätigen', 'security.confirmDisableMfa': 'Alle MFA-Faktoren deaktivieren?', @@ -3348,6 +3373,7 @@ const translations = { 'transfer.error.SHARE_UNAVAILABLE': 'Die SMB-Freigabe ist nicht verfügbar.', 'transfer.error.TIMEOUT': 'Zeitüberschreitung beim Dateivorgang.', 'transfer.error.SOURCE_UNAVAILABLE': 'Die Dateiquelle ist nicht mehr verfügbar. Stellen Sie die Verbindung erneut her.', + 'transfer.error.SOURCE_CHANGED': 'Die Quelle wurde während des Transfers geändert. Versuchen Sie es erneut.', 'transfer.error.LIMIT_EXCEEDED': 'Der Transfer überschreitet das konfigurierte Limit.', 'transfer.limit.message': '{actual} überschreitet das {limit}-Limit für {kind}.', 'transfer.limit.kind.upload': 'Uploads', @@ -3546,6 +3572,7 @@ const translations = { 'fm.emptyDirectory': 'Leeres Verzeichnis', 'fm.selectSourceAbove': 'Wählen Sie oben eine Quelle aus', 'fm.loading': 'Laden...', + 'fm.loadMore': 'Mehr laden', 'fm.connectionTimeout': 'Verbindungs-Timeout - Verzeichnis konnte nicht geladen werden', 'fm.items': 'Elemente', 'fm.selected': 'ausgewählt', @@ -4017,6 +4044,8 @@ const translations = { 'connection.lostReconnecting': 'Connexion perdue. Reconnexion en cours...', 'connection.lostReconnectingAttempt': 'Connexion perdue. Reconnexion en cours... (tentative {attempt})', 'connection.disconnectedFromServer': 'Déconnecté du serveur', + 'connection.reloadRequired': 'WebSSH a été mis à jour. Rechargez cette page pour continuer.', + 'connection.reloadPage': 'Recharger la page', 'keys.manageKeys': 'Gérer les clés', 'keys.manageSSHKeys': 'Gérer les clés SSH', @@ -4131,6 +4160,7 @@ const translations = { 'smb.error.timeout': 'Le serveur SMB n’a pas répondu à temps.', 'smb.error.encryption': 'Le serveur ne prend pas en charge le chiffrement SMB requis.', 'smb.error.dialect': 'Le serveur ne prend pas en charge SMB 3.1.1.', + 'smb.error.identityUnavailable': 'Ce serveur SMB ne peut pas fournir les identifiants de fichier stables nécessaires à un accès sécurisé.', 'smb.error.shutdown': 'Le serveur est en cours d’arrêt.', 'smb.error.invalid': 'Vérifiez les informations de connexion et réessayez.', 'smb.error.connection': 'La connexion SMB n’a pas pu être établie.', @@ -4284,6 +4314,9 @@ const translations = { 'security.authenticatorDeleted': "Application d’authentification supprimée", 'security.confirmWithDirectory': 'Confirmez avec le mot de passe utilisé pour la connexion à l’annuaire.', 'security.confirmWithTotp': 'Saisissez un code actuel de votre application d’authentification.', + 'security.confirmWithBootstrap': 'Saisissez le code d’inscription à usage unique émis par l’opérateur WebSSH.', + 'security.bootstrapCode': 'Code d’inscription', + 'security.bootstrapCodeRequired': 'Le code d’inscription est requis.', 'security.directoryPassword': 'Mot de passe de l’annuaire', 'security.methodLdap': 'Mot de passe de l’annuaire', 'security.methodOidc': 'Fournisseur d’identité', @@ -4300,6 +4333,7 @@ const translations = { 'security.githubDisconnected': 'GitHub déconnecté', 'security.methodPasskey': "Clé d’accès", 'security.methodPassword': 'Mot de passe WebSSH', + 'security.methodBootstrap': 'Code d’inscription', 'security.methodTotp': "Application d’authentification", 'security.confirmSecurityAction': 'Confirmer l’action de sécurité', 'security.confirmDisableMfa': 'Désactiver tous les facteurs MFA ?', @@ -4581,6 +4615,7 @@ const translations = { 'transfer.error.SHARE_UNAVAILABLE': 'Le partage SMB n’est pas disponible.', 'transfer.error.TIMEOUT': 'L’opération sur les fichiers a expiré.', 'transfer.error.SOURCE_UNAVAILABLE': 'La source de fichiers n’est plus disponible. Reconnectez-vous et réessayez.', + 'transfer.error.SOURCE_CHANGED': 'La source a changé pendant le transfert. Réessayez.', 'transfer.error.LIMIT_EXCEEDED': 'Le transfert dépasse la limite configurée.', 'transfer.limit.message': '{actual} dépasse la limite de {limit} pour {kind}.', 'transfer.limit.kind.upload': 'le téléversement', @@ -4825,6 +4860,7 @@ const translations = { 'fm.emptyDirectory': 'Répertoire vide', 'fm.selectSourceAbove': 'Sélectionnez une source ci-dessus', 'fm.loading': 'Chargement...', + 'fm.loadMore': 'Charger plus', 'fm.connectionTimeout': 'Délai de connexion - impossible de charger le répertoire', 'fm.items': 'éléments', 'fm.selected': 'sélectionné(s)', @@ -5287,6 +5323,8 @@ const translations = { 'connection.lostReconnecting': 'Conexión perdida. Reconectando...', 'connection.lostReconnectingAttempt': 'Conexión perdida. Reconectando... (intento {attempt})', 'connection.disconnectedFromServer': 'Desconectado del servidor', + 'connection.reloadRequired': 'WebSSH se ha actualizado. Recarga esta página para continuar.', + 'connection.reloadPage': 'Recargar página', 'keys.manageKeys': 'Administrar claves', 'keys.manageSSHKeys': 'Administrar claves SSH', @@ -5401,6 +5439,7 @@ const translations = { 'smb.error.timeout': 'El servidor SMB no respondió a tiempo.', 'smb.error.encryption': 'El servidor no admite el cifrado SMB requerido.', 'smb.error.dialect': 'El servidor no admite SMB 3.1.1.', + 'smb.error.identityUnavailable': 'Este servidor SMB no puede proporcionar los identificadores de archivo estables necesarios para un acceso seguro.', 'smb.error.shutdown': 'El servidor se está apagando.', 'smb.error.invalid': 'Comprueba los datos de conexión y vuelve a intentarlo.', 'smb.error.connection': 'No se pudo establecer la conexión SMB.', @@ -5554,6 +5593,9 @@ const translations = { 'security.authenticatorDeleted': 'Aplicación de autenticación eliminada', 'security.confirmWithDirectory': 'Confirma con la contraseña que utilizas para iniciar sesión en el directorio.', 'security.confirmWithTotp': 'Introduce un código actual de tu aplicación de autenticación.', + 'security.confirmWithBootstrap': 'Introduce el código de inscripción de un solo uso emitido por el operador de WebSSH.', + 'security.bootstrapCode': 'Código de inscripción', + 'security.bootstrapCodeRequired': 'Se requiere el código de inscripción.', 'security.directoryPassword': 'Contraseña del directorio', 'security.methodLdap': 'Contraseña del directorio', 'security.methodOidc': 'Proveedor de identidad', @@ -5570,6 +5612,7 @@ const translations = { 'security.githubDisconnected': 'GitHub desconectado', 'security.methodPasskey': 'Passkey', 'security.methodPassword': 'Contraseña de WebSSH', + 'security.methodBootstrap': 'Código de inscripción', 'security.methodTotp': 'Aplicación de autenticación', 'security.confirmSecurityAction': 'Confirmar acción de seguridad', 'security.confirmDisableMfa': '¿Desactivar todos los factores MFA?', @@ -5851,6 +5894,7 @@ const translations = { 'transfer.error.SHARE_UNAVAILABLE': 'El recurso compartido SMB no está disponible.', 'transfer.error.TIMEOUT': 'La operación de archivos agotó el tiempo de espera.', 'transfer.error.SOURCE_UNAVAILABLE': 'La fuente de archivos ya no está disponible. Vuelve a conectarla e inténtalo de nuevo.', + 'transfer.error.SOURCE_CHANGED': 'La fuente cambió durante la transferencia. Inténtalo de nuevo.', 'transfer.error.LIMIT_EXCEEDED': 'La transferencia supera el límite configurado.', 'transfer.limit.message': '{actual} supera el límite de {limit} para {kind}.', 'transfer.limit.kind.upload': 'la carga', @@ -6095,6 +6139,7 @@ const translations = { 'fm.emptyDirectory': 'Directorio vacío', 'fm.selectSourceAbove': 'Selecciona una fuente arriba', 'fm.loading': 'Cargando...', + 'fm.loadMore': 'Cargar más', 'fm.connectionTimeout': 'Tiempo de conexión agotado - no se pudo cargar el directorio', 'fm.items': 'elementos', 'fm.selected': 'seleccionado(s)', @@ -6557,6 +6602,8 @@ const translations = { 'connection.lostReconnecting': '连接已中断。正在重新连接...', 'connection.lostReconnectingAttempt': '连接已中断。正在重新连接...(第 {attempt} 次尝试)', 'connection.disconnectedFromServer': '已断开与服务器的连接', + 'connection.reloadRequired': 'WebSSH 已更新。请重新加载此页面以继续。', + 'connection.reloadPage': '重新加载页面', 'keys.manageKeys': '管理密钥', 'keys.manageSSHKeys': '管理 SSH 密钥', @@ -6671,6 +6718,7 @@ const translations = { 'smb.error.timeout': 'SMB 服务器未及时响应。', 'smb.error.encryption': '服务器不支持所需的 SMB 加密。', 'smb.error.dialect': '服务器不支持 SMB 3.1.1。', + 'smb.error.identityUnavailable': '此 SMB 服务器无法提供安全访问所需的稳定文件标识符。', 'smb.error.shutdown': '服务器正在关闭。', 'smb.error.invalid': '请检查连接信息后重试。', 'smb.error.connection': '无法建立 SMB 连接。', @@ -6824,6 +6872,9 @@ const translations = { 'security.authenticatorDeleted': '身份验证器应用已删除', 'security.confirmWithDirectory': '使用目录登录密码进行确认。', 'security.confirmWithTotp': '输入身份验证器应用中的当前代码。', + 'security.confirmWithBootstrap': '输入 WebSSH 管理员签发的一次性注册代码。', + 'security.bootstrapCode': '注册代码', + 'security.bootstrapCodeRequired': '请输入注册代码。', 'security.directoryPassword': '目录密码', 'security.methodLdap': '目录密码', 'security.methodOidc': '身份提供商', @@ -6840,6 +6891,7 @@ const translations = { 'security.githubDisconnected': 'GitHub 已断开', 'security.methodPasskey': '通行密钥', 'security.methodPassword': 'WebSSH 密码', + 'security.methodBootstrap': '注册代码', 'security.methodTotp': '身份验证器应用', 'security.confirmSecurityAction': '确认安全操作', 'security.confirmDisableMfa': '停用所有 MFA 因素?', @@ -7121,6 +7173,7 @@ const translations = { 'transfer.error.SHARE_UNAVAILABLE': 'SMB 共享不可用。', 'transfer.error.TIMEOUT': '文件操作超时。', 'transfer.error.SOURCE_UNAVAILABLE': '文件源已不可用。请重新连接后再试。', + 'transfer.error.SOURCE_CHANGED': '传输期间源发生了变化。请重试。', 'transfer.error.LIMIT_EXCEEDED': '传输超过了配置的限制。', 'transfer.limit.message': '{kind}大小 {actual} 超过了 {limit} 的限制。', 'transfer.limit.kind.upload': '上传', @@ -7356,6 +7409,7 @@ const translations = { 'fm.emptyDirectory': '目录为空', 'fm.selectSourceAbove': '请先在上方选择来源', 'fm.loading': '加载中...', + 'fm.loadMore': '加载更多', 'fm.connectionTimeout': '连接超时,无法加载目录', 'fm.items': '项', 'fm.selected': '已选择', diff --git a/static/js/jump-host-manager.js b/static/js/jump-host-manager.js index 2d66141c..bbefb534 100644 --- a/static/js/jump-host-manager.js +++ b/static/js/jump-host-manager.js @@ -22,6 +22,20 @@ window.JumpHostManager = { } }, + upsertJumpHost(jumpHost) { + if (!jumpHost || !jumpHost.id) return; + this.setJumpHosts(this.jumpHosts.some(item => item.id === jumpHost.id) + ? this.jumpHosts.map(item => item.id === jumpHost.id + ? {...item, ...jumpHost} + : item) + : [...this.jumpHosts, jumpHost]); + }, + + removeJumpHost(jumpHostId) { + if (!jumpHostId) return; + this.setJumpHosts(this.jumpHosts.filter(item => item.id !== jumpHostId)); + }, + getById(id) { return this.jumpHosts.find(j => j.id === id) || null; }, diff --git a/static/js/profile-manager.js b/static/js/profile-manager.js index 486e9d4c..4fc6a1d2 100644 --- a/static/js/profile-manager.js +++ b/static/js/profile-manager.js @@ -254,6 +254,20 @@ const ProfileManager = { } }, + upsertProfile(profile) { + if (!profile || !profile.id) return; + this.setProfiles(this.profiles.some(item => item.id === profile.id) + ? this.profiles.map(item => item.id === profile.id + ? profile + : item) + : [...this.profiles, profile]); + }, + + removeProfile(profileId) { + if (!profileId) return; + this.setProfiles(this.profiles.filter(item => item.id !== profileId)); + }, + setKeys(keys) { this.keys = Array.isArray(keys) ? keys : []; this.renderKeySelect(); @@ -1337,12 +1351,17 @@ const ProfileManager = { return; } const transientAuthorization = profile.tailscale_authorized; + const acknowledgedAuthorization = ( + typeof acknowledgement.profile.tailscale_authorized === 'boolean' + ? acknowledgement.profile.tailscale_authorized + : transientAuthorization + ); this.profiles = this.profiles.map(item => item.id === profileId ? { ...acknowledgement.profile, - ...(transientAuthorization === undefined + ...(acknowledgedAuthorization === undefined ? {} - : {tailscale_authorized: transientAuthorization}), + : {tailscale_authorized: acknowledgedAuthorization}), } : item); this.renderProfileSelect(); @@ -1368,6 +1387,31 @@ const ProfileManager = { return true; }, + applyOrganizationPatch(organization) { + if (!Array.isArray(organization)) return false; + const changes = new Map(organization + .filter(item => item && typeof item.id === 'string') + .map(item => [item.id, item])); + this.profiles = this.profiles.map(profile => { + const patch = changes.get(profile.id); + if (!patch) return profile; + const updated = {...profile}; + if (typeof patch.group === 'string' && patch.group) { + updated.group = patch.group; + } else { + delete updated.group; + } + if (Number.isInteger(patch.sort_order) && patch.sort_order >= 0) { + updated.sort_order = patch.sort_order; + } + if (typeof patch.updated_at === 'string') { + updated.updated_at = patch.updated_at; + } + return updated; + }); + return true; + }, + requestProfileMove(move, emit = null, confirmed = false) { const profile = this.profiles.find(item => item.id === move?.profileId); if (!profile || this.organizationPending.has(profile.id)) return false; @@ -1391,8 +1435,8 @@ const ProfileManager = { this.renderManagementList(); send(payload, acknowledgement => { this.organizationPending.delete(profile.id); - if (Array.isArray(acknowledgement?.profiles)) { - this.adoptAuthoritativeProfiles(acknowledgement.profiles); + if (Array.isArray(acknowledgement?.organization)) { + this.applyOrganizationPatch(acknowledgement.organization); } if (acknowledgement?.requires_confirmation === true) { this.pendingProfileMove = { @@ -1406,7 +1450,8 @@ const ProfileManager = { this.openProfileMoveConfirmation(); return; } - if (!acknowledgement?.success || !Array.isArray(acknowledgement.profiles)) { + if (!acknowledgement?.success + || !Array.isArray(acknowledgement.organization)) { window.showNotification?.( acknowledgement?.error || this.t( 'profiles.saveFailed', 'Failed to save connection' diff --git a/static/js/security-ui.js b/static/js/security-ui.js index 0bf7771b..81b292d2 100644 --- a/static/js/security-ui.js +++ b/static/js/security-ui.js @@ -136,6 +136,15 @@ if (!created.methods.includes(method)) { throw new Error('No supported authentication method is available.'); } + if (method === 'bootstrap') { + const secret = await requestSecret(method); + if (secret === null || secret === undefined) { return null; } + const completed = await api('/api/account/step-up/bootstrap', { + method: 'POST', + body: { intent: created.intent, code: secret } + }); + return completed.grant; + } if (method === 'password' || method === 'ldap') { const secret = await requestSecret(method); if (secret === null || secret === undefined) { return null; } diff --git a/static/js/session-manager.js b/static/js/session-manager.js index 260a958d..31d7ed7b 100644 --- a/static/js/session-manager.js +++ b/static/js/session-manager.js @@ -1,5 +1,8 @@ /* exported SessionManager */ const SessionManager = { + legacyDisplayNameStorageKey: 'sessionDisplayNames', + displayNameStoragePrefix: 'sessionDisplayNames:', + activeDisplayNameScopeKey: 'sessionDisplayNames:activeScope', sessions: {}, activeSessionId: null, pendingConnections: {}, @@ -12,6 +15,7 @@ const SessionManager = { ) ? document.body.dataset.disconnectSessionAction : 'retry', init() { + this.initializeDisplayNameStorage(); if (window.socket) { window.socket.on('ssh_session_restored', (data) => { this.restoreSession(data); @@ -32,6 +36,56 @@ const SessionManager = { this.updateSessionMeta(null); }, + displayNameScope() { + return String(document.body?.dataset.connectionHistoryScope || '').trim(); + }, + + displayNameStorageKey() { + const scope = this.displayNameScope(); + return scope ? `${this.displayNameStoragePrefix}${scope}` : null; + }, + + initializeDisplayNameStorage() { + try { + localStorage.removeItem(this.legacyDisplayNameStorageKey); + localStorage.removeItem(this.activeDisplayNameScopeKey); + } catch { + // Session aliases are optional when browser storage is unavailable. + } + }, + + readDisplayNames() { + const key = this.displayNameStorageKey(); + if (!key) return {}; + try { + const value = JSON.parse(localStorage.getItem(key) || '{}'); + return value && typeof value === 'object' && !Array.isArray(value) + ? value + : {}; + } catch { + return {}; + } + }, + + writeDisplayNames(value) { + const key = this.displayNameStorageKey(); + if (!key) return; + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + console.error('Failed to save session display name'); + } + }, + + clearScopedBrowserStorage() { + try { + localStorage.removeItem(this.legacyDisplayNameStorageKey); + localStorage.removeItem(this.activeDisplayNameScopeKey); + } catch { + // Logout must continue even when browser storage is unavailable. + } + }, + restoreSession(data) { const sessionId = data.session_id; @@ -114,12 +168,10 @@ const SessionManager = { // Save display name to localStorage by host:port:user key if (display_name) { - try { - const stored = JSON.parse(localStorage.getItem('sessionDisplayNames') || '{}'); - const hostKey = `${host}:${port}:${username}`; - stored[hostKey] = display_name; - localStorage.setItem('sessionDisplayNames', JSON.stringify(stored)); - } catch {} + const stored = this.readDisplayNames(); + const hostKey = `${host}:${port}:${username}`; + stored[hostKey] = display_name; + this.writeDisplayNames(stored); } this.createSessionTab(session_id); @@ -672,27 +724,22 @@ const SessionManager = { saveSessionDisplayName(sessionId, displayName) { const session = this.sessions[sessionId]; - // Save to localStorage by session ID - try { - const stored = JSON.parse(localStorage.getItem('sessionDisplayNames') || '{}'); + const stored = this.readDisplayNames(); + if (displayName) { + stored[sessionId] = displayName; + } else { + delete stored[sessionId]; + } + // Also save by host:port:user key so it survives session ID changes. + if (session) { + const hostKey = `${session.host}:${session.port}:${session.username}`; if (displayName) { - stored[sessionId] = displayName; + stored[hostKey] = displayName; } else { - delete stored[sessionId]; - } - // Also save by host:port:user key so it survives session ID changes - if (session) { - const hostKey = `${session.host}:${session.port}:${session.username}`; - if (displayName) { - stored[hostKey] = displayName; - } else { - delete stored[hostKey]; - } + delete stored[hostKey]; } - localStorage.setItem('sessionDisplayNames', JSON.stringify(stored)); - } catch { - console.error('Failed to save session display name'); } + this.writeDisplayNames(stored); // Save to server DB if (window.socket) { window.socket.emit('save_session_name', { @@ -703,19 +750,15 @@ const SessionManager = { }, getStoredDisplayName(sessionId, host, port, username) { - try { - const stored = JSON.parse(localStorage.getItem('sessionDisplayNames') || '{}'); - // Check by session ID first - if (stored[sessionId]) return stored[sessionId]; - // Check by host:port:user key (persists across session ID changes) - if (host && port && username) { - const hostKey = `${host}:${port}:${username}`; - if (stored[hostKey]) return stored[hostKey]; - } - return null; - } catch { - return null; - } + const stored = this.readDisplayNames(); + // Check by session ID first. + if (stored[sessionId]) return stored[sessionId]; + // Check by host:port:user key (persists across session ID changes). + if (host && port && username) { + const hostKey = `${host}:${port}:${username}`; + if (stored[hostKey]) return stored[hostKey]; + } + return null; }, updateSessionMeta(sessionId) { diff --git a/static/js/sftp-file-manager.js b/static/js/sftp-file-manager.js index 36daeea6..4de4ffc4 100644 --- a/static/js/sftp-file-manager.js +++ b/static/js/sftp-file-manager.js @@ -61,6 +61,11 @@ class SFTPFileManager { pendingHomeRequestId: null, pendingDirectoryRequestId: null, pendingDirectoryPath: null, + pendingDirectoryCursor: 0, + nextDirectoryCursor: null, + loadingMore: false, + directoryContinuationView: null, + directoryNeedsRefresh: false, autoHomeEligible: false }; } @@ -716,6 +721,7 @@ class SFTPFileManager { if (!tab) return null; this.syncPaneFromWorkspace(pane); this.setActivePane(pane); + this.resumeDirectoryListingIfNeeded(pane, tab.paneState); this.updatePathInput(pane, tab.paneState.path || '/'); this.updatePaneBadge(pane); this.renderPane(pane); @@ -726,8 +732,10 @@ class SFTPFileManager { closeSourceTab(pane, tabId) { const result = this.workspace.closeTab(pane, tabId); if (!result.closed) return null; + this.cancelDirectoryListingForState(result.closed.paneState); this.releaseQuickConnectionIfUnused(result.closed.source); this.syncPaneFromWorkspace(pane); + this.resumeDirectoryListingIfNeeded(pane, result.active?.paneState); this.updatePathInput(pane, this.panes[pane].path || '/'); this.updatePaneBadge(pane); this.renderPane(pane); @@ -1676,27 +1684,50 @@ class SFTPFileManager { if (!this.socket) return; this.socket.on('directory_listing', (data) => { - if (this.consumeMovePickerListing(data)) return; + let consumed = this.consumeMovePickerListing(data); this.getPaneStateEntries().forEach(({ pane, state, visible }) => { - if (this.getPaneSourceId(state) === data.source_id && + if (!consumed && + this.getPaneSourceId(state) === data.source_id && state.pendingDirectoryRequestId === data.request_id && - state.pendingDirectoryPath === data.path) { + state.pendingDirectoryPath === data.path && + state.pendingDirectoryCursor === (data.cursor ?? 0)) { if (state.loadingTimeout) { clearTimeout(state.loadingTimeout); state.loadingTimeout = null; } - state.files = data.files || []; + const cursor = data.cursor ?? 0; + state.files = cursor !== 0 + ? [...state.files, ...(data.files || [])] + : (data.files || []); state.path = data.path; state.loading = false; + state.loadingMore = false; state.error = null; + state.directoryNeedsRefresh = false; + state.nextDirectoryCursor = this.isDirectoryContinuationCursor( + data.next_cursor, + ) + ? data.next_cursor + : null; state.pendingDirectoryRequestId = null; state.pendingDirectoryPath = null; + state.pendingDirectoryCursor = 0; if (visible) { this.updatePathInput(pane, data.path); this.renderPane(pane); + this.restorePaneContinuationView(pane, state, data); + } else { + state.directoryContinuationView = null; } + consumed = true; } }); + if (!consumed && this.isDirectoryContinuationCursor(data?.next_cursor)) { + // A page-zero or continuation response can arrive after its + // tab, path, or picker was abandoned. Retire the exact server + // snapshot instead of holding its SFTP/SMB channel until TTL. + this.cancelDirectoryCursor(data?.source_id, data.next_cursor); + } }); this.socket.on('home_directory', (data) => { @@ -1801,22 +1832,7 @@ class SFTPFileManager { 'The destination folder could not be opened.', ), )) return; - this.getPaneStateEntries().forEach(({ pane, state, visible }) => { - if (state.loading && this.getPaneSourceId(state) === data.source_id && - state.pendingDirectoryRequestId === data.request_id && - state.pendingDirectoryPath === data.path) { - if (state.loadingTimeout) { - clearTimeout(state.loadingTimeout); - state.loadingTimeout = null; - } - state.loading = false; - state.error = errorMsg; - state.pendingDirectoryRequestId = null; - state.pendingDirectoryPath = null; - if (visible) this.renderPane(pane); - if (this.isOpen !== false) this.showNotification(errorMsg, 'error'); - } - }); + this.consumePaneDirectoryError(data, errorMsg); }); this.socket.on('file_exists_result', (data) => { @@ -1837,7 +1853,8 @@ class SFTPFileManager { return this.getPaneStateEntries().some(({ state }) => { return this.getPaneSourceId(state) === data.source_id && state.pendingDirectoryRequestId === data.request_id - && state.pendingDirectoryPath === data.path; + && state.pendingDirectoryPath === data.path + && state.pendingDirectoryCursor === (data.cursor ?? 0); }); } const pending = this.pendingOperationRequests?.get(data?.request_id); @@ -1848,6 +1865,55 @@ class SFTPFileManager { ); } + consumePaneDirectoryError(data, errorMessage) { + let consumed = false; + this.getPaneStateEntries().forEach(({ pane, state, visible }) => { + if (consumed + || !(state.loading || state.loadingMore) + || this.getPaneSourceId(state) !== data?.source_id + || state.pendingDirectoryRequestId !== data?.request_id + || state.pendingDirectoryPath !== data?.path + || state.pendingDirectoryCursor !== (data?.cursor ?? 0)) return; + + if (state.loadingTimeout) { + clearTimeout(state.loadingTimeout); + state.loadingTimeout = null; + } + const wasLoadingMore = state.loadingMore; + if (!wasLoadingMore && state.pendingDirectoryCursor === 0) { + this.cancelDirectoryRequest( + this.getPaneSourceId(state), + state.pendingDirectoryRequestId, + ); + } + state.loading = false; + state.loadingMore = false; + state.error = wasLoadingMore ? null : errorMessage; + state.pendingDirectoryRequestId = null; + state.pendingDirectoryPath = null; + state.pendingDirectoryCursor = 0; + state.directoryContinuationView = null; + if (wasLoadingMore) { + // Continuation handles are intentionally short-lived. Restart + // once from page zero so an expired or retired snapshot cannot + // strand the pane on a dead cursor. + const recoveryRequestId = this.requestDirectoryForState( + pane, + state, + state.path, + ); + if (state.loading + && state.pendingDirectoryRequestId === recoveryRequestId) { + this.setLoadingTimeout(pane, 10000, state); + } + } + if (visible) this.renderPane(pane); + if (this.isOpen !== false) this.showNotification(errorMessage, 'error'); + consumed = true; + }); + return consumed; + } + getPaneStateEntries() { if (this.displayMode === 'embedded' || !this.workspace) { return ['left', 'right'] @@ -1972,6 +2038,7 @@ class SFTPFileManager { this.loadWorkspaceProfiles(); ['left', 'right'].forEach(pane => { this.syncPaneFromWorkspace(pane); + this.resumeDirectoryListingIfNeeded(pane, this.panes[pane]); this.updatePathInput(pane, this.panes[pane].path || '/'); this.updatePaneBadge(pane); this.renderPane(pane); @@ -1989,6 +2056,11 @@ class SFTPFileManager { const wasPrimaryWorkspace = primaryWorkspace?.isElementActive(this.modal) === true; const embeddedTarget = this.suspendedEmbeddedTarget; this.suspendedEmbeddedTarget = null; + this.getPaneStateEntries().forEach(({ state }) => { + this.cancelDirectoryListingForState(state, { + refreshOnNextOpen: true, + }); + }); this.isOpen = false; this.displayMode = 'closed'; this.closeMovePicker({ restoreFocus: false }); @@ -2171,7 +2243,10 @@ class SFTPFileManager { return sourceId === `sftp-session:${sessionId}` || sourceId === `sftp-quick:${sessionId}`; }); - matchingTabs.forEach(tab => this.workspace.closeTab(pane, tab.id)); + matchingTabs.forEach(tab => { + this.cancelDirectoryListingForState(tab.paneState); + this.workspace.closeTab(pane, tab.id); + }); this.syncPaneFromWorkspace(pane); if (this.isOpen && this.displayMode === 'modal') { this.updatePathInput(pane, this.panes[pane].path || '/'); @@ -2190,7 +2265,10 @@ class SFTPFileManager { if (sourceId === `sftp-session:${sessionId}` || sourceId === `sftp-quick:${sessionId}`) { if (visible) this.resetPane(pane); - else Object.assign(state, this.createEmptyPaneState()); + else { + this.cancelDirectoryListingForState(state); + Object.assign(state, this.createEmptyPaneState()); + } } }); this.updateSessionLists(); @@ -2246,9 +2324,7 @@ class SFTPFileManager { return; } - if (state.loadingTimeout) { - clearTimeout(state.loadingTimeout); - } + this.cancelDirectoryListingForState(state); Object.keys(state).forEach(key => delete state[key]); Object.assign(state, this.createEmptyPaneState()); state.loading = true; @@ -2304,9 +2380,9 @@ class SFTPFileManager { } this.requestHomeDirectory(pane); - this.requestDirectory(pane, '/'); + const requestId = this.requestDirectory(pane, '/'); this.updatePaneBadge(pane); - this.setLoadingTimeout(pane); + if (requestId) this.setLoadingTimeout(pane); } nextRequestId(pane, operation) { @@ -2369,36 +2445,253 @@ class SFTPFileManager { return this.requestDirectoryForState(pane, state, path); } + cancelDirectoryCursor(sourceId, cursor) { + if (!sourceId + || !this.isDirectoryContinuationCursor(cursor) + || !this.socket?.emit) return false; + try { + this.socket.emit('cancel_directory_listing', { + source_id: sourceId, + request_id: this.nextRequestId('directory', 'cancel'), + cursor, + }); + return true; + } catch { + return false; + } + } + + cancelDirectoryRequest(sourceId, listingRequestId) { + if (!sourceId + || !this.isDirectoryRequestId(listingRequestId) + || !this.socket?.emit) return false; + try { + this.socket.emit('cancel_directory_listing', { + source_id: sourceId, + request_id: this.nextRequestId('directory', 'cancel'), + listing_request_id: listingRequestId, + }); + return true; + } catch { + return false; + } + } + + cancelDirectoryListingForState(state, options = {}) { + if (!state) return false; + const sourceId = this.getPaneSourceId(state); + let emitted = false; + if (state.pendingDirectoryCursor === 0) { + emitted = this.cancelDirectoryRequest( + sourceId, + state.pendingDirectoryRequestId, + ) || emitted; + } + const cursors = new Set([ + state.nextDirectoryCursor, + state.pendingDirectoryCursor, + ].filter(cursor => this.isDirectoryContinuationCursor(cursor))); + cursors.forEach(cursor => { + emitted = this.cancelDirectoryCursor(sourceId, cursor) || emitted; + }); + if (state.loadingTimeout) clearTimeout(state.loadingTimeout); + const shouldRefresh = options.refreshOnNextOpen === true + && Boolean(sourceId) + && this.sourceCan(state, 'list'); + state.loadingTimeout = null; + state.loading = false; + state.loadingMore = false; + state.pendingDirectoryRequestId = null; + state.pendingDirectoryPath = null; + state.pendingDirectoryCursor = 0; + state.nextDirectoryCursor = null; + state.directoryContinuationView = null; + state.directoryNeedsRefresh = shouldRefresh; + return emitted; + } + + resumeDirectoryListingIfNeeded(pane, state) { + if (!state?.directoryNeedsRefresh + || !this.getPaneSourceId(state) + || !this.sourceCan(state, 'list')) return false; + const requestId = this.requestDirectoryForState( + pane, + state, + state.path || '/', + ); + if (!requestId) { + state.directoryNeedsRefresh = true; + return false; + } + this.setLoadingTimeout(pane); + return true; + } + requestDirectoryForState(pane, state, path) { + this.cancelDirectoryListingForState(state); state.selected?.clear(); state.path = path; state.loading = true; + state.loadingMore = false; + state.directoryContinuationView = null; + state.nextDirectoryCursor = null; + state.directoryNeedsRefresh = false; const requestId = this.nextRequestId(pane, 'directory'); state.pendingDirectoryRequestId = requestId; state.pendingDirectoryPath = path; - this.socket.emit('list_directory', { - source_id: this.getPaneSourceId(state), - remote_path: path, - request_id: requestId, - }); + state.pendingDirectoryCursor = 0; + const sourceId = this.getPaneSourceId(state); + try { + this.socket.emit('list_directory', { + source_id: sourceId, + remote_path: path, + request_id: requestId, + cursor: 0, + }); + } catch { + this.consumePaneDirectoryError({ + operation: 'list_directory', + source_id: sourceId, + request_id: requestId, + path, + cursor: 0, + }, this.t('fm.sourceUnavailable', 'This file source is no longer available')); + return null; + } return requestId; } - setLoadingTimeout(pane, timeout = 10000) { + requestNextDirectoryPage(pane) { const state = this.panes[pane]; + const cursor = state?.nextDirectoryCursor; + if (!this.isDirectoryContinuationCursor(cursor) || state.loadingMore) return false; + if (state.loadingTimeout) { + clearTimeout(state.loadingTimeout); + state.loadingTimeout = null; + } + const requestId = this.nextRequestId(pane, 'directory-page'); + const requestPath = state.path; + state.loadingMore = true; + state.pendingDirectoryRequestId = requestId; + state.pendingDirectoryPath = requestPath; + state.pendingDirectoryCursor = cursor; + const sourceId = this.getPaneSourceId(state); + this.capturePaneContinuationView(pane, state, requestId, cursor); + try { + this.socket.emit('list_directory', { + source_id: sourceId, + remote_path: requestPath, + request_id: requestId, + cursor, + }); + } catch { + this.consumePaneDirectoryError({ + operation: 'list_directory', + source_id: sourceId, + request_id: requestId, + path: requestPath, + cursor, + }, this.t('fm.sourceUnavailable', 'This file source is no longer available')); + return true; + } + this.setLoadingTimeout(pane); + return true; + } + + paneListElement(pane) { + return document.getElementById(`fm${this.capitalize(pane)}List`); + } + + capturePaneContinuationView(pane, state, requestId, cursor) { + const container = this.paneListElement(pane); + if (!container) { + state.directoryContinuationView = null; + return; + } + const loadMore = container.querySelector?.('[data-load-more]') || null; + state.directoryContinuationView = { + requestId, + cursor, + scrollTop: Number(container.scrollTop) || 0, + restoreFocus: document.activeElement === loadMore, + }; + container.setAttribute?.('aria-busy', 'true'); + if (loadMore) { + loadMore.disabled = true; + loadMore.setAttribute?.('aria-busy', 'true'); + loadMore.textContent = this.t('fm.loading', 'Loading...'); + } + } + + restorePaneContinuationView(pane, state, data) { + const view = state.directoryContinuationView; + if (!view + || view.requestId !== data?.request_id + || view.cursor !== (data?.cursor ?? 0)) return false; + state.directoryContinuationView = null; + const container = this.paneListElement(pane); + if (!container) return false; + container.scrollTop = view.scrollTop; + if (view.restoreFocus) { + const loadMore = container.querySelector?.('[data-load-more]') || null; + const focusTarget = loadMore || container; + if (!loadMore) container.setAttribute?.('tabindex', '-1'); + focusTarget.focus?.({ preventScroll: true }); + container.scrollTop = view.scrollTop; + } + return true; + } + + isDirectoryContinuationCursor(cursor) { + if (Number.isInteger(cursor)) return cursor > 0; + return typeof cursor === 'string' + && cursor.length >= 1 + && cursor.length <= 160 + && /^[A-Za-z0-9._-]+$/.test(cursor); + } + + isDirectoryRequestId(requestId) { + return typeof requestId === 'string' + && requestId.length >= 1 + && requestId.length <= 128 + && /^[A-Za-z0-9:._-]+$/.test(requestId); + } + + setLoadingTimeout(pane, timeout = 10000, targetState = null) { + const state = targetState || this.panes[pane]; + if (!state?.loading && !state?.loadingMore) return false; if (state.loadingTimeout) { clearTimeout(state.loadingTimeout); + state.loadingTimeout = null; } - state.loadingTimeout = setTimeout(() => { - if (state.loading) { - state.loading = false; - state.error = this.t('fm.connectionTimeout', 'Connection timeout - could not load directory'); - this.renderPane(pane); - this.showNotification(this.t('fm.loadTimeout', 'Failed to load directory: timeout'), 'error'); - } + const sourceId = this.getPaneSourceId(state); + const requestId = state.pendingDirectoryRequestId; + const requestPath = state.pendingDirectoryPath; + const requestCursor = state.pendingDirectoryCursor; + if (!sourceId || !requestId || !requestPath) return false; + + const loadingTimeout = setTimeout(() => { + if (state.loadingTimeout !== loadingTimeout + || this.getPaneSourceId(state) !== sourceId + || state.pendingDirectoryRequestId !== requestId + || state.pendingDirectoryPath !== requestPath + || state.pendingDirectoryCursor !== requestCursor + || (!state.loading && !state.loadingMore)) return; + this.consumePaneDirectoryError({ + operation: 'list_directory', + source_id: sourceId, + request_id: requestId, + path: requestPath, + cursor: requestCursor, + }, this.t( + 'fm.connectionTimeout', + 'Connection timeout - could not load directory', + )); }, timeout); + state.loadingTimeout = loadingTimeout; + return true; } updatePaneBadge(pane) { @@ -2599,8 +2892,8 @@ class SFTPFileManager { state.loading = true; this.renderPane(pane); - this.requestDirectory(pane, path); - this.setLoadingTimeout(pane); + const requestId = this.requestDirectory(pane, path); + if (requestId) this.setLoadingTimeout(pane); } async navigatePaneUp(pane) { @@ -2648,8 +2941,8 @@ class SFTPFileManager { state.autoHomeEligible = false; state.loading = true; this.renderPane(pane); - this.requestDirectory(pane, state.path); - this.setLoadingTimeout(pane); + const requestId = this.requestDirectory(pane, state.path); + if (requestId) this.setLoadingTimeout(pane); } refreshBothPanes() { @@ -2661,19 +2954,21 @@ class SFTPFileManager { this.getPaneStateEntries().forEach(({ pane, state, visible }) => { if (this.getPaneSourceId(state) !== sourceId || !this.sourceCan(state, 'list')) return; - this.requestDirectoryForState(pane, state, state.path || '/'); + const requestId = this.requestDirectoryForState( + pane, + state, + state.path || '/', + ); if (visible) { this.renderPane(pane); - this.setLoadingTimeout(pane); + if (requestId) this.setLoadingTimeout(pane); } }); } resetPane(pane) { const state = this.panes[pane]; - if (state.loadingTimeout) { - clearTimeout(state.loadingTimeout); - } + this.cancelDirectoryListingForState(state); Object.keys(state).forEach(key => delete state[key]); Object.assign(state, this.createEmptyPaneState()); const select = document.getElementById(`fm${this.capitalize(pane)}Source`); @@ -2690,6 +2985,7 @@ class SFTPFileManager { renderPane(pane) { const state = this.panes[pane]; const container = document.getElementById(`fm${this.capitalize(pane)}List`); + container.setAttribute?.('aria-busy', String(state.loading || state.loadingMore)); if (state.loading) { container.innerHTML = ` @@ -2804,8 +3100,27 @@ class SFTPFileManager { `; }).join(''); + if (this.isDirectoryContinuationCursor(state.nextDirectoryCursor)) { + html += ` + + `; + } + container.innerHTML = html; + const loadMore = container.querySelector?.('[data-load-more]') || null; + if (loadMore) { + loadMore.disabled = state.loadingMore; + loadMore.setAttribute?.('aria-busy', String(state.loadingMore)); + loadMore.addEventListener('click', () => { + this.requestNextDirectoryPage(pane); + }); + } + container.querySelectorAll('.fm-file-item').forEach(item => { const index = parseInt(item.dataset.index); item.addEventListener('click', (e) => this.handleItemClick(e, pane, index)); @@ -3438,6 +3753,16 @@ class SFTPFileManager { 'click', () => void this.confirmMovePicker(), ); dialog.querySelector('[data-move-picker-list]').addEventListener('click', event => { + if (event.target.closest('[data-move-picker-more]')) { + const picker = this.movePicker; + if (this.isDirectoryContinuationCursor(picker?.nextCursor)) { + this.requestMovePickerDirectory( + picker.targetPath, + picker.nextCursor, + ); + } + return; + } const button = event.target.closest('[data-move-picker-directory]'); if (!button || !this.movePicker) return; const directory = this.movePicker.directories[ @@ -3522,9 +3847,14 @@ class SFTPFileManager { targetPath: sourcePath, pendingPath: null, pendingRequestId: null, + pendingCursor: 0, + nextCursor: null, listingTimeout: null, loading: false, + loadingMore: false, + continuationView: null, error: null, + continuationError: null, validTarget: false, directories: [], focusListAfterRender: false, @@ -3543,7 +3873,7 @@ class SFTPFileManager { closeMovePicker(options = {}) { const picker = this.movePicker; if (!picker) return false; - if (picker.listingTimeout) clearTimeout(picker.listingTimeout); + this.cancelMovePickerDirectoryListing(picker); picker.element?.remove(); this.movePicker = null; if (options.restoreFocus !== false) picker.previousFocus?.focus?.(); @@ -3556,42 +3886,87 @@ class SFTPFileManager { return normalized.split('/').slice(0, -1).join('/') || '/'; } - requestMovePickerDirectory(path) { + cancelMovePickerDirectoryListing(picker = this.movePicker) { + if (!picker) return false; + let emitted = false; + if (picker.pendingCursor === 0) { + emitted = this.cancelDirectoryRequest( + picker.sourceId, + picker.pendingRequestId, + ) || emitted; + } + const cursors = new Set([ + picker.nextCursor, + picker.pendingCursor, + ].filter(cursor => this.isDirectoryContinuationCursor(cursor))); + cursors.forEach(cursor => { + emitted = this.cancelDirectoryCursor( + picker.sourceId, + cursor, + ) || emitted; + }); + if (picker.listingTimeout) clearTimeout(picker.listingTimeout); + picker.listingTimeout = null; + picker.loading = false; + picker.loadingMore = false; + picker.pendingPath = null; + picker.pendingRequestId = null; + picker.pendingCursor = 0; + picker.nextCursor = null; + picker.continuationView = null; + return emitted; + } + + requestMovePickerDirectory(path, cursor = 0) { const picker = this.movePicker; const targetPath = this.canonicalMovePath(path); if (!picker || !targetPath || !this.socket?.emit) return false; - if (picker.listingTimeout) clearTimeout(picker.listingTimeout); + const loadingMore = cursor !== 0; + if (!loadingMore) this.cancelMovePickerDirectoryListing(picker); + else if (picker.listingTimeout) clearTimeout(picker.listingTimeout); + const requestId = this.nextRequestId('move-picker', 'directory'); picker.pendingPath = targetPath; - picker.pendingRequestId = this.nextRequestId('move-picker', 'directory'); + picker.pendingRequestId = requestId; picker.loading = true; + picker.loadingMore = loadingMore; picker.error = null; - picker.validTarget = false; - picker.directories = []; + picker.continuationError = null; + if (!loadingMore) picker.validTarget = false; + if (loadingMore) { + this.captureMovePickerContinuationView(picker, requestId, cursor); + } else { + picker.directories = []; + picker.nextCursor = null; + picker.continuationView = null; + } + picker.pendingCursor = cursor; picker.listingTimeout = setTimeout(() => { - if (this.movePicker !== picker || !picker.loading) return; - picker.loading = false; - picker.error = this.t( + this.consumeMovePickerError({ + operation: 'list_directory', + source_id: picker.sourceId, + path: targetPath, + request_id: requestId, + cursor, + }, this.t( 'fm.movePickerListFailed', 'The destination folder could not be opened.', - ); - picker.pendingRequestId = null; - picker.pendingPath = null; - picker.listingTimeout = null; - this.renderMovePicker(); + )); }, this.movePickerListingTimeoutMs || 10000); - this.renderMovePicker(); + this.renderMovePicker({ preserveList: loadingMore }); try { this.socket.emit('list_directory', { source_id: picker.sourceId, remote_path: targetPath, - request_id: picker.pendingRequestId, + request_id: requestId, + cursor, }); } catch { this.consumeMovePickerError({ operation: 'list_directory', source_id: picker.sourceId, path: targetPath, - request_id: picker.pendingRequestId, + request_id: requestId, + cursor, }, this.t( 'fm.movePickerListFailed', 'The destination folder could not be opened.', @@ -3600,6 +3975,40 @@ class SFTPFileManager { return true; } + captureMovePickerContinuationView(picker, requestId, cursor) { + const list = picker.element?.querySelector?.('[data-move-picker-list]') || null; + if (!list) { + picker.continuationView = null; + return; + } + const loadMore = list.querySelector?.('[data-move-picker-more]') || null; + picker.continuationView = { + requestId, + cursor, + scrollTop: Number(list.scrollTop) || 0, + restoreFocus: document.activeElement === loadMore, + }; + } + + restoreMovePickerContinuationView(picker, data) { + const view = picker.continuationView; + if (!view + || view.requestId !== data?.request_id + || view.cursor !== (data?.cursor ?? 0)) return false; + picker.continuationView = null; + const list = picker.element?.querySelector?.('[data-move-picker-list]') || null; + if (!list) return false; + list.scrollTop = view.scrollTop; + if (view.restoreFocus) { + const loadMore = list.querySelector?.('[data-move-picker-more]') || null; + const focusTarget = loadMore || list; + if (!loadMore) list.setAttribute?.('tabindex', '-1'); + focusTarget.focus?.({ preventScroll: true }); + list.scrollTop = view.scrollTop; + } + return true; + } + matchesMovePickerRequest(data) { const picker = this.movePicker; const responsePath = this.canonicalMovePath(data?.path); @@ -3611,6 +4020,7 @@ class SFTPFileManager { && picker.loading && data?.source_id === picker.sourceId && data?.request_id === picker.pendingRequestId + && (data?.cursor ?? 0) === picker.pendingCursor && responsePath && fold(responsePath) === fold(picker.pendingPath), ); @@ -3622,13 +4032,17 @@ class SFTPFileManager { if (picker.listingTimeout) clearTimeout(picker.listingTimeout); const targetPath = this.canonicalMovePath(data.path); picker.loading = false; + picker.loadingMore = false; picker.error = null; + picker.continuationError = null; picker.validTarget = Boolean(targetPath); picker.targetPath = targetPath || picker.targetPath; + const cursor = picker.pendingCursor; picker.pendingPath = null; picker.pendingRequestId = null; + picker.pendingCursor = 0; picker.listingTimeout = null; - picker.directories = (Array.isArray(data.files) ? data.files : []) + const directories = (Array.isArray(data.files) ? data.files : []) .filter(item => ( item?.is_dir === true && typeof item.name === 'string' @@ -3645,7 +4059,15 @@ class SFTPFileManager { })) .filter(item => item.path) .sort((left, right) => left.name.localeCompare(right.name)); + picker.directories = cursor !== 0 + ? [...picker.directories, ...directories] + .sort((left, right) => left.name.localeCompare(right.name)) + : directories; + picker.nextCursor = this.isDirectoryContinuationCursor(data.next_cursor) + ? data.next_cursor + : null; this.renderMovePicker(); + this.restoreMovePickerContinuationView(picker, data); return true; } @@ -3653,21 +4075,49 @@ class SFTPFileManager { if (!this.matchesMovePickerRequest(data)) return false; const picker = this.movePicker; if (picker.listingTimeout) clearTimeout(picker.listingTimeout); - picker.loading = false; - picker.error = errorMessage || this.t( + const wasLoadingMore = picker.loadingMore + && this.isDirectoryContinuationCursor(picker.pendingCursor); + const message = errorMessage || this.t( 'fm.movePickerListFailed', 'The destination folder could not be opened.', ); + if (wasLoadingMore) { + const recoveryPath = picker.targetPath; + this.cancelMovePickerDirectoryListing(picker); + // The server retires a failed continuation. Restart once from + // page zero so the picker cannot retain an enabled dead cursor. + if (!this.requestMovePickerDirectory(recoveryPath)) { + picker.error = message; + picker.continuationError = null; + picker.validTarget = false; + picker.directories = []; + this.renderMovePicker(); + } + return true; + } + if (picker.pendingCursor === 0) { + this.cancelDirectoryRequest( + picker.sourceId, + picker.pendingRequestId, + ); + } + picker.loading = false; + picker.loadingMore = false; + picker.error = message; + picker.continuationError = null; + picker.continuationView = null; picker.validTarget = false; + picker.directories = []; + picker.nextCursor = null; picker.pendingPath = null; picker.pendingRequestId = null; + picker.pendingCursor = 0; picker.listingTimeout = null; - picker.directories = []; this.renderMovePicker(); return true; } - renderMovePicker() { + renderMovePicker({ preserveList = false } = {}) { const picker = this.movePicker; if (!picker?.element) return; const displayPath = picker.pendingPath || picker.targetPath || '/'; @@ -3681,13 +4131,14 @@ class SFTPFileManager { if (refresh) refresh.disabled = picker.loading; const reason = this.movePickerTargetReason(picker); + const listingError = picker.error || picker.continuationError; const status = picker.element.querySelector('[data-move-picker-status]'); if (status) { - status.dataset.state = picker.error ? 'error' : reason || 'ready'; + status.dataset.state = listingError ? 'error' : reason || 'ready'; status.textContent = picker.loading ? this.t('fm.loading', 'Loading...') - : picker.error - ? picker.error + : listingError + ? listingError : reason === 'same-folder' ? this.t( 'fm.movePickerChooseDifferent', @@ -3711,18 +4162,33 @@ class SFTPFileManager { const list = picker.element.querySelector('[data-move-picker-list]'); if (list) { list.setAttribute('aria-busy', String(picker.loading)); - list.innerHTML = picker.loading - ? `
${this.escapeHtml(this.t('fm.loading', 'Loading...'))}
` - : picker.error - ? `
${this.escapeHtml(this.t('fm.movePickerListFailed', 'The destination folder could not be opened.'))}
` - : picker.directories.length === 0 - ? `
${this.escapeHtml(this.t('fm.movePickerNoFolders', 'No subfolders'))}
` - : picker.directories.map((directory, index) => ` - `).join(''); + if (!preserveList) { + list.innerHTML = picker.loading && !picker.loadingMore + ? `
${this.escapeHtml(this.t('fm.loading', 'Loading...'))}
` + : picker.error + ? `
${this.escapeHtml(this.t('fm.movePickerListFailed', 'The destination folder could not be opened.'))}
` + : picker.directories.length === 0 + && !this.isDirectoryContinuationCursor(picker.nextCursor) + ? `
${this.escapeHtml(this.t('fm.movePickerNoFolders', 'No subfolders'))}
` + : picker.directories.map((directory, index) => ` + `).join('') + ( + this.isDirectoryContinuationCursor(picker.nextCursor) + ? `` + : '' + ); + } + const loadMore = list.querySelector?.('[data-move-picker-more]') || null; + if (loadMore) { + loadMore.disabled = picker.loadingMore; + loadMore.setAttribute?.('aria-busy', String(picker.loadingMore)); + if (picker.loadingMore) { + loadMore.textContent = this.t('fm.loading', 'Loading...'); + } + } } const confirm = picker.element.querySelector('[data-move-picker-confirm]'); if (confirm) { @@ -4786,6 +5252,7 @@ class SFTPFileManager { SHARE_UNAVAILABLE: 'The SMB share is unavailable.', TIMEOUT: 'The file operation timed out.', SOURCE_UNAVAILABLE: 'The file source is no longer available. Reconnect and try again.', + SOURCE_CHANGED: 'The source changed during the transfer. Try again.', LIMIT_EXCEEDED: 'The transfer exceeds the configured limit.', CANCELLED: 'The transfer was cancelled.', ATOMIC_REPLACE_UNAVAILABLE: 'Safe overwrite is unavailable for this destination.', diff --git a/static/js/smb-source-dialog.js b/static/js/smb-source-dialog.js index 405b0add..2bce3341 100644 --- a/static/js/smb-source-dialog.js +++ b/static/js/smb-source-dialog.js @@ -12,6 +12,7 @@ TIMEOUT: ['smb.error.timeout', 'The SMB server did not respond in time.'], ENCRYPTION_REQUIRED: ['smb.error.encryption', 'The server does not support the required SMB encryption.'], DIALECT_REQUIRED: ['smb.error.dialect', 'The server does not support SMB 3.1.1.'], + IDENTITY_UNAVAILABLE: ['smb.error.identityUnavailable', 'This SMB server cannot provide the stable file identities required for secure access.'], RUNTIME_SHUTTING_DOWN: ['smb.error.shutdown', 'The server is shutting down.'], INVALID_REQUEST: ['smb.error.invalid', 'Check the connection details and try again.'], CONNECTION_FAILED: ['smb.error.connection', 'The SMB connection could not be established.'], diff --git a/static/js/socket-protocol.js b/static/js/socket-protocol.js new file mode 100644 index 00000000..8e0f33cb --- /dev/null +++ b/static/js/socket-protocol.js @@ -0,0 +1,100 @@ +(function(root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) { + module.exports = api; + } + if (root) { + root.WebSSHSocketProtocol = api; + } +})(typeof window !== 'undefined' ? window : globalThis, function() { + 'use strict'; + + const WIRE_REVISION = 1; + const MISMATCH_EVENT = 'socket_protocol_mismatch'; + const RELOAD_GUARD_KEY = 'webssh:socket-protocol-reload'; + + function mismatchMarker(payload) { + const requiredRevision = Number.isInteger(payload?.required_revision) + ? payload.required_revision + : 'unknown'; + return `${WIRE_REVISION}:${requiredRevision}`; + } + + function isCompatibleServer(payload) { + return ( + payload?.status === 'success' + && payload.wire_revision === WIRE_REVISION + ); + } + + function createMismatchController(options = {}) { + const storage = options.storage || null; + const disconnect = options.disconnect; + const reload = options.reload; + const showManualReload = options.showManualReload; + let handled = false; + + function clearReloadGuard() { + try { + storage?.removeItem(RELOAD_GUARD_KEY); + } catch { + // Restricted storage is a supported degraded browser mode. + } + } + + function markCompatible() { + handled = false; + // Keep the revision-pair guard for this tab. During a rolling + // deployment a later reconnect can reach the incompatible backend + // again; a newly loaded client revision computes a different marker. + } + + function handleMismatch(payload = {}) { + if (handled) return 'ignored'; + handled = true; + + try { + disconnect?.(); + } catch { + // Continue to the safe reload path even if disconnect fails. + } + + const marker = mismatchMarker(payload); + let reloadArmed = false; + if (storage && typeof reload === 'function') { + try { + if (storage.getItem(RELOAD_GUARD_KEY) !== marker) { + storage.setItem(RELOAD_GUARD_KEY, marker); + reloadArmed = ( + storage.getItem(RELOAD_GUARD_KEY) === marker + ); + } + } catch { + reloadArmed = false; + } + } + + if (reloadArmed) { + reload(); + return 'reload'; + } + + showManualReload?.(payload); + return 'manual'; + } + + return Object.freeze({ + clearReloadGuard, + handleMismatch, + markCompatible, + }); + } + + return Object.freeze({ + MISMATCH_EVENT, + RELOAD_GUARD_KEY, + WIRE_REVISION, + createMismatchController, + isCompatibleServer, + }); +}); diff --git a/static/js/terminal-manager.js b/static/js/terminal-manager.js index cd7d8722..31728458 100644 --- a/static/js/terminal-manager.js +++ b/static/js/terminal-manager.js @@ -62,7 +62,9 @@ const TerminalManager = { }; }, - decodeOsc52Clipboard(data, maxBytes = 1024 * 1024) { + // Keep the encoded form below xterm's deterministic 200,000-character + // OSC/DCS parser ceiling (128 KiB becomes at most 174,764 base64 chars). + decodeOsc52Clipboard(data, maxBytes = 128 * 1024) { if (typeof data !== 'string') return null; const separator = data.indexOf(';'); if (separator < 0) return null; diff --git a/static/js/webauthn.js b/static/js/webauthn.js index 0e016646..fb238a5b 100644 --- a/static/js/webauthn.js +++ b/static/js/webauthn.js @@ -131,7 +131,7 @@ labelDefault: '', hint: 'Confirm this account security change.' }, options || {}); - const passwordAuthentication = ['password', 'ldap'].includes( + const passwordAuthentication = ['password', 'ldap', 'bootstrap'].includes( settings.authentication ); document.getElementById('securityConfirmationHint').textContent = settings.hint; @@ -151,7 +151,8 @@ passkey: t('security.methodPasskey', 'Passkey'), totp: t('security.methodTotp', 'Authenticator app'), ldap: t('security.methodLdap', 'Directory password'), - password: t('security.methodPassword', 'WebSSH password') + password: t('security.methodPassword', 'WebSSH password'), + bootstrap: t('security.methodBootstrap', 'Enrollment code') }; for (const method of methodChoices) { const option = document.createElement('option'); @@ -160,9 +161,15 @@ option.selected = method === settings.preferredMethod; methodSelect.appendChild(option); } + const passwordInput = document.getElementById('securityConfirmationPassword'); + passwordInput.autocomplete = settings.authentication === 'bootstrap' + ? 'one-time-code' + : 'current-password'; document.getElementById('securityConfirmationPasswordText').textContent = settings.authentication === 'ldap' ? t('security.directoryPassword', 'Directory password') - : t('auth.currentPassword', 'Current password'); + : settings.authentication === 'bootstrap' + ? t('security.bootstrapCode', 'Enrollment code') + : t('auth.currentPassword', 'Current password'); document.getElementById('securityConfirmationLabelGroup').classList.toggle('hidden', !settings.label); document.getElementById('securityConfirmationAccountGroup').classList.toggle('hidden', !settings.account); document.getElementById('securityConfirmationLabel').value = settings.labelDefault; @@ -172,7 +179,7 @@ const firstField = methodChoices.length ? methodSelect : passwordAuthentication - ? document.getElementById('securityConfirmationPassword') + ? passwordInput : settings.authentication === 'totp' ? document.getElementById('securityConfirmationTotp') : settings.label @@ -191,11 +198,13 @@ if (Array.isArray(settings.methodChoices) && settings.methodChoices.length) { result.method = document.getElementById('securityConfirmationMethod').value; } - if (['password', 'ldap'].includes(settings.authentication)) { + if (['password', 'ldap', 'bootstrap'].includes(settings.authentication)) { result.secret = document.getElementById('securityConfirmationPassword').value; if (!result.secret) { const error = document.getElementById('securityConfirmationError'); - error.textContent = t('auth.currentPasswordRequired', 'Current password is required.'); + error.textContent = settings.authentication === 'bootstrap' + ? t('security.bootstrapCodeRequired', 'Enrollment code is required.') + : t('auth.currentPasswordRequired', 'Current password is required.'); error.classList.remove('hidden'); return; } @@ -238,7 +247,9 @@ ? t('security.confirmWithDirectory', 'Confirm with the password you use for directory sign-in.') : method === 'totp' ? t('security.confirmWithTotp', 'Enter a current code from your authenticator app.') - : t('security.confirmFactorChange', 'Confirm this account security change.') + : method === 'bootstrap' + ? t('security.confirmWithBootstrap', 'Enter the one-time enrollment code issued by the WebSSH operator.') + : t('security.confirmFactorChange', 'Confirm this account security change.') }); return result === null ? null : result.secret; } diff --git a/static/vendor/xterm/xterm.js b/static/vendor/xterm/xterm.js index e47e2ddd..9769fdfe 100644 --- a/static/vendor/xterm/xterm.js +++ b/static/vendor/xterm/xterm.js @@ -1,2 +1,2 @@ -!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(globalThis,(()=>(()=>{"use strict";var e={2840:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AccessibilityManager=void 0;const n=i(7721),o=i(4292),a=i(7150),l=i(7098),h=i(6501),c=i(7093);let d=class extends a.Disposable{constructor(e,t,i,s){super(),this._terminal=e,this._coreBrowserService=i,this._renderService=s,this._rowColumns=new WeakMap,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="";const r=this._coreBrowserService.mainDocument;this._accessibilityContainer=r.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=r.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let e=0;ethis._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=r.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this._register(new o.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this._register(this._terminal.onResize((e=>this._handleResize(e.rows)))),this._register(this._terminal.onRender((e=>this._refreshRows(e.start,e.end)))),this._register(this._terminal.onScroll((()=>this._refreshRows()))),this._register(this._terminal.onA11yChar((e=>this._handleChar(e)))),this._register(this._terminal.onLineFeed((()=>this._handleChar("\n")))),this._register(this._terminal.onA11yTab((e=>this._handleTab(e)))),this._register(this._terminal.onKey((e=>this._handleKey(e.key)))),this._register(this._terminal.onBlur((()=>this._clearLiveRegion()))),this._register(this._renderService.onDimensionsChange((()=>this._refreshRowsDimensions()))),this._register((0,c.addDisposableListener)(r,"selectionchange",(()=>this._handleSelectionChange()))),this._register(this._coreBrowserService.onDprChange((()=>this._refreshRowsDimensions()))),this._refreshRowsDimensions(),this._refreshRows(),this._register((0,a.toDisposable)((()=>{this._accessibilityContainer.remove(),this._rowElements.length=0})))}_handleTab(e){for(let t=0;t0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent+=n.tooMuchOutput.get())))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.lines.get(i.ydisp+r),t=[],n=e?.translateToString(!0,void 0,void 0,t)||"",o=(i.ydisp+r+1).toString(),a=this._rowElements[r];a&&(0===n.length?(a.textContent=" ",this._rowColumns.set(a,[0,1])):(a.textContent=n,this._rowColumns.set(a,t)),a.setAttribute("aria-posinset",o),a.setAttribute("aria-setsize",s),this._alignRowWidth(a))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,n;if(0===t?(r=i,n=this._rowElements.pop(),this._rowContainer.removeChild(n)):(r=this._rowElements.shift(),n=i,this._rowContainer.removeChild(r)),r.removeEventListener("focus",this._topBoundaryFocusListener),n.removeEventListener("focus",this._bottomBoundaryFocusListener),0===t){const e=this._createAccessibilityTreeNode();this._rowElements.unshift(e),this._rowContainer.insertAdjacentElement("afterbegin",e)}else{const e=this._createAccessibilityTreeNode();this._rowElements.push(e),this._rowContainer.appendChild(e)}this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._terminal.scrollLines(0===t?-1:1),this._rowElements[0===t?1:this._rowElements.length-2].focus(),e.preventDefault(),e.stopImmediatePropagation()}_handleSelectionChange(){if(0===this._rowElements.length)return;const e=this._coreBrowserService.mainDocument.getSelection();if(!e)return;if(e.isCollapsed)return void(this._rowContainer.contains(e.anchorNode)&&this._terminal.clearSelection());if(!e.anchorNode||!e.focusNode)return void console.error("anchorNode and/or focusNode are null");let t={node:e.anchorNode,offset:e.anchorOffset},i={node:e.focusNode,offset:e.focusOffset};if((t.node.compareDocumentPosition(i.node)&Node.DOCUMENT_POSITION_PRECEDING||t.node===i.node&&t.offset>i.offset)&&([t,i]=[i,t]),t.node.compareDocumentPosition(this._rowElements[0])&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_FOLLOWING)&&(t={node:this._rowElements[0].childNodes[0],offset:0}),!this._rowContainer.contains(t.node))return;const s=this._rowElements.slice(-1)[0];if(i.node.compareDocumentPosition(s)&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_PRECEDING)&&(i={node:s,offset:s.textContent?.length??0}),!this._rowContainer.contains(i.node))return;const r=({node:e,offset:t})=>{const i=e instanceof Text?e.parentNode:e;let s=parseInt(i?.getAttribute("aria-posinset"),10)-1;if(isNaN(s))return console.warn("row is invalid. Race condition?"),null;const r=this._rowColumns.get(i);if(!r)return console.warn("columns is null. Race condition?"),null;let n=t=this._terminal.cols&&(++s,n=0),{row:s,column:n}},n=r(t),o=r(i);if(n&&o){if(n.row>o.row||n.row===o.row&&n.column>=o.column)throw new Error("invalid range");this._terminal.select(n.column,n.row,(o.row-n.row)*this._terminal.cols-n.column+o.column)}}_handleResize(e){this._rowElements[this._rowElements.length-1].removeEventListener("focus",this._bottomBoundaryFocusListener);for(let e=this._rowContainer.children.length;ee;)this._rowContainer.removeChild(this._rowElements.pop());this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions()}_createAccessibilityTreeNode(){const e=this._coreBrowserService.mainDocument.createElement("div");return e.setAttribute("role","listitem"),e.tabIndex=-1,this._refreshRowDimensions(e),e}_refreshRowsDimensions(){if(this._renderService.dimensions.css.cell.height){Object.assign(this._accessibilityContainer.style,{width:`${this._renderService.dimensions.css.canvas.width}px`,fontSize:`${this._terminal.options.fontSize}px`}),this._rowElements.length!==this._terminal.rows&&this._handleResize(this._terminal.rows);for(let e=0;e{function i(e){return e.replace(/\r?\n/g,"\r")}function s(e,t){return t?"[200~"+e+"[201~":e}function r(e,t,r,n){e=s(e=i(e),r.decPrivateModes.bracketedPasteMode&&!0!==n.rawOptions.ignoreBracketedPasteMode),r.triggerDataEvent(e,!0),t.value=""}function n(e,t,i){const s=i.getBoundingClientRect(),r=e.clientX-s.left-10,n=e.clientY-s.top-10;t.style.width="20px",t.style.height="20px",t.style.left=`${r}px`,t.style.top=`${n}px`,t.style.zIndex="1000",t.focus()}Object.defineProperty(t,"__esModule",{value:!0}),t.prepareTextForTerminal=i,t.bracketTextForPaste=s,t.copyHandler=function(e,t){e.clipboardData&&e.clipboardData.setData("text/plain",t.selectionText),e.preventDefault()},t.handlePasteEvent=function(e,t,i,s){e.stopPropagation(),e.clipboardData&&r(e.clipboardData.getData("text/plain"),t,i,s)},t.paste=r,t.moveTextAreaUnderMouseCursor=n,t.rightClickHandler=function(e,t,i,s,r){n(e,t,i),r&&s.rightClickSelect(e),t.value=s.selectionText,t.select()}},7174:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ColorContrastCache=void 0;const s=i(7710);t.ColorContrastCache=class{constructor(){this._color=new s.TwoKeyMap,this._css=new s.TwoKeyMap}setCss(e,t,i){this._css.set(e,t,i)}getCss(e,t){return this._css.get(e,t)}setColor(e,t,i){this._color.set(e,t,i)}getColor(e,t){return this._color.get(e,t)}clear(){this._color.clear(),this._css.clear()}}},1718:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreBrowserTerminal=void 0;const s=i(7861),r=i(7721),n=i(3285),o=i(4017),a=i(4196),l=i(9925),h=i(3618),c=i(3955),d=i(4792),u=i(945),_=i(9574),f=i(9820),p=i(9784),g=i(5783),m=i(2079),v=i(7098),S=i(9078),b=i(4103),C=i(5777),y=i(701),w=i(6107),E=i(3534),D=i(706),L=i(8693),R=i(4720),A=i(6501),T=i(2486),k=i(2840),M=i(8906),O=i(802),I=i(7093),P=i(7150);class x extends C.CoreTerminal{get linkifier(){return this._linkifier.value}get onFocus(){return this._onFocus.event}get onBlur(){return this._onBlur.event}get onA11yChar(){return this._onA11yCharEmitter.event}get onA11yTab(){return this._onA11yTabEmitter.event}get onWillOpen(){return this._onWillOpen.event}constructor(e={}){super(e),this._linkifier=this._register(new P.MutableDisposable),this.browser=y,this._keyDownHandled=!1,this._keyDownSeen=!1,this._keyPressHandled=!1,this._unprocessedDeadKey=!1,this._accessibilityManager=this._register(new P.MutableDisposable),this._onCursorMove=this._register(new O.Emitter),this.onCursorMove=this._onCursorMove.event,this._onKey=this._register(new O.Emitter),this.onKey=this._onKey.event,this._onRender=this._register(new O.Emitter),this.onRender=this._onRender.event,this._onSelectionChange=this._register(new O.Emitter),this.onSelectionChange=this._onSelectionChange.event,this._onTitleChange=this._register(new O.Emitter),this.onTitleChange=this._onTitleChange.event,this._onBell=this._register(new O.Emitter),this.onBell=this._onBell.event,this._onFocus=this._register(new O.Emitter),this._onBlur=this._register(new O.Emitter),this._onA11yCharEmitter=this._register(new O.Emitter),this._onA11yTabEmitter=this._register(new O.Emitter),this._onWillOpen=this._register(new O.Emitter),this._setup(),this._decorationService=this._instantiationService.createInstance(R.DecorationService),this._instantiationService.setService(A.IDecorationService,this._decorationService),this._linkProviderService=this._instantiationService.createInstance(f.LinkProviderService),this._instantiationService.setService(v.ILinkProviderService,this._linkProviderService),this._linkProviderService.registerLinkProvider(this._instantiationService.createInstance(n.OscLinkProvider)),this._register(this._inputHandler.onRequestBell((()=>this._onBell.fire()))),this._register(this._inputHandler.onRequestRefreshRows((e=>this.refresh(e?.start??0,e?.end??this.rows-1)))),this._register(this._inputHandler.onRequestSendFocus((()=>this._reportFocus()))),this._register(this._inputHandler.onRequestReset((()=>this.reset()))),this._register(this._inputHandler.onRequestWindowsOptionsReport((e=>this._reportWindowsOptions(e)))),this._register(this._inputHandler.onColor((e=>this._handleColorEvent(e)))),this._register(O.Event.forward(this._inputHandler.onCursorMove,this._onCursorMove)),this._register(O.Event.forward(this._inputHandler.onTitleChange,this._onTitleChange)),this._register(O.Event.forward(this._inputHandler.onA11yChar,this._onA11yCharEmitter)),this._register(O.Event.forward(this._inputHandler.onA11yTab,this._onA11yTabEmitter)),this._register(this._bufferService.onResize((e=>this._afterResize(e.cols,e.rows)))),this._register((0,P.toDisposable)((()=>{this._customKeyEventHandler=void 0,this.element?.parentNode?.removeChild(this.element)})))}_handleColorEvent(e){if(this._themeService)for(const t of e){let e,i="";switch(t.index){case 256:e="foreground",i="10";break;case 257:e="background",i="11";break;case 258:e="cursor",i="12";break;default:e="ansi",i="4;"+t.index}switch(t.type){case 0:const s=b.color.toColorRGB("ansi"===e?this._themeService.colors.ansi[t.index]:this._themeService.colors[e]);this.coreService.triggerDataEvent(`${E.C0.ESC}]${i};${(0,L.toRgbString)(s)}${E.C1_ESCAPED.ST}`);break;case 1:if("ansi"===e)this._themeService.modifyColors((e=>e.ansi[t.index]=b.channels.toColor(...t.color)));else{const i=e;this._themeService.modifyColors((e=>e[i]=b.channels.toColor(...t.color)))}break;case 2:this._themeService.restoreColor(t.index)}}}_setup(){super._setup(),this._customKeyEventHandler=void 0}get buffer(){return this.buffers.active}focus(){this.textarea&&this.textarea.focus({preventScroll:!0})}_handleScreenReaderModeOptionChange(e){e?!this._accessibilityManager.value&&this._renderService&&(this._accessibilityManager.value=this._instantiationService.createInstance(k.AccessibilityManager,this)):this._accessibilityManager.clear()}_handleTextAreaFocus(e){this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(E.C0.ESC+"[I"),this.element.classList.add("focus"),this._showCursor(),this._onFocus.fire()}blur(){return this.textarea?.blur()}_handleTextAreaBlur(){this.textarea.value="",this.refresh(this.buffer.y,this.buffer.y),this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(E.C0.ESC+"[O"),this.element.classList.remove("focus"),this._onBlur.fire()}_syncTextArea(){if(!this.textarea||!this.buffer.isCursorInViewport||this._compositionHelper.isComposing||!this._renderService)return;const e=this.buffer.ybase+this.buffer.y,t=this.buffer.lines.get(e);if(!t)return;const i=Math.min(this.buffer.x,this.cols-1),s=this._renderService.dimensions.css.cell.height,r=t.getWidth(i),n=this._renderService.dimensions.css.cell.width*r,o=this.buffer.y*this._renderService.dimensions.css.cell.height,a=i*this._renderService.dimensions.css.cell.width;this.textarea.style.left=a+"px",this.textarea.style.top=o+"px",this.textarea.style.width=n+"px",this.textarea.style.height=s+"px",this.textarea.style.lineHeight=s+"px",this.textarea.style.zIndex="-5"}_initGlobal(){this._bindKeys(),this._register((0,I.addDisposableListener)(this.element,"copy",(e=>{this.hasSelection()&&(0,s.copyHandler)(e,this._selectionService)})));const e=e=>(0,s.handlePasteEvent)(e,this.textarea,this.coreService,this.optionsService);this._register((0,I.addDisposableListener)(this.textarea,"paste",e)),this._register((0,I.addDisposableListener)(this.element,"paste",e)),y.isFirefox?this._register((0,I.addDisposableListener)(this.element,"mousedown",(e=>{2===e.button&&(0,s.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)}))):this._register((0,I.addDisposableListener)(this.element,"contextmenu",(e=>{(0,s.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)}))),y.isLinux&&this._register((0,I.addDisposableListener)(this.element,"auxclick",(e=>{1===e.button&&(0,s.moveTextAreaUnderMouseCursor)(e,this.textarea,this.screenElement)})))}_bindKeys(){this._register((0,I.addDisposableListener)(this.textarea,"keyup",(e=>this._keyUp(e)),!0)),this._register((0,I.addDisposableListener)(this.textarea,"keydown",(e=>this._keyDown(e)),!0)),this._register((0,I.addDisposableListener)(this.textarea,"keypress",(e=>this._keyPress(e)),!0)),this._register((0,I.addDisposableListener)(this.textarea,"compositionstart",(()=>this._compositionHelper.compositionstart()))),this._register((0,I.addDisposableListener)(this.textarea,"compositionupdate",(e=>this._compositionHelper.compositionupdate(e)))),this._register((0,I.addDisposableListener)(this.textarea,"compositionend",(()=>this._compositionHelper.compositionend()))),this._register((0,I.addDisposableListener)(this.textarea,"input",(e=>this._inputEvent(e)),!0)),this._register(this.onRender((()=>this._compositionHelper.updateCompositionElements())))}open(e){if(!e)throw new Error("Terminal requires a parent element.");if(e.isConnected||this._logService.debug("Terminal.open was called on an element that was not attached to the DOM"),this.element?.ownerDocument.defaultView&&this._coreBrowserService)return void(this.element.ownerDocument.defaultView!==this._coreBrowserService.window&&(this._coreBrowserService.window=this.element.ownerDocument.defaultView));this._document=e.ownerDocument,this.options.documentOverride&&this.options.documentOverride instanceof Document&&(this._document=this.optionsService.rawOptions.documentOverride),this.element=this._document.createElement("div"),this.element.dir="ltr",this.element.classList.add("terminal"),this.element.classList.add("xterm"),e.appendChild(this.element);const t=this._document.createDocumentFragment();this._viewportElement=this._document.createElement("div"),this._viewportElement.classList.add("xterm-viewport"),t.appendChild(this._viewportElement),this.screenElement=this._document.createElement("div"),this.screenElement.classList.add("xterm-screen"),this._register((0,I.addDisposableListener)(this.screenElement,"mousemove",(e=>this.updateCursorStyle(e)))),this._helperContainer=this._document.createElement("div"),this._helperContainer.classList.add("xterm-helpers"),this.screenElement.appendChild(this._helperContainer),t.appendChild(this.screenElement);const i=this.textarea=this._document.createElement("textarea");this.textarea.classList.add("xterm-helper-textarea"),this.textarea.setAttribute("aria-label",r.promptLabel.get()),y.isChromeOS||this.textarea.setAttribute("aria-multiline","false"),this.textarea.setAttribute("autocorrect","off"),this.textarea.setAttribute("autocapitalize","off"),this.textarea.setAttribute("spellcheck","false"),this.textarea.tabIndex=0,this._register(this.optionsService.onSpecificOptionChange("disableStdin",(()=>i.readOnly=this.optionsService.rawOptions.disableStdin))),this.textarea.readOnly=this.optionsService.rawOptions.disableStdin,this._coreBrowserService=this._register(this._instantiationService.createInstance(_.CoreBrowserService,this.textarea,e.ownerDocument.defaultView??window,this._document??"undefined"!=typeof window?window.document:null)),this._instantiationService.setService(v.ICoreBrowserService,this._coreBrowserService),this._register((0,I.addDisposableListener)(this.textarea,"focus",(e=>this._handleTextAreaFocus(e)))),this._register((0,I.addDisposableListener)(this.textarea,"blur",(()=>this._handleTextAreaBlur()))),this._helperContainer.appendChild(this.textarea),this._charSizeService=this._instantiationService.createInstance(d.CharSizeService,this._document,this._helperContainer),this._instantiationService.setService(v.ICharSizeService,this._charSizeService),this._themeService=this._instantiationService.createInstance(S.ThemeService),this._instantiationService.setService(v.IThemeService,this._themeService),this._characterJoinerService=this._instantiationService.createInstance(u.CharacterJoinerService),this._instantiationService.setService(v.ICharacterJoinerService,this._characterJoinerService),this._renderService=this._register(this._instantiationService.createInstance(g.RenderService,this.rows,this.screenElement)),this._instantiationService.setService(v.IRenderService,this._renderService),this._register(this._renderService.onRenderedViewportChange((e=>this._onRender.fire(e)))),this.onResize((e=>this._renderService.resize(e.cols,e.rows))),this._compositionView=this._document.createElement("div"),this._compositionView.classList.add("composition-view"),this._compositionHelper=this._instantiationService.createInstance(h.CompositionHelper,this.textarea,this._compositionView),this._helperContainer.appendChild(this._compositionView),this._mouseService=this._instantiationService.createInstance(p.MouseService),this._instantiationService.setService(v.IMouseService,this._mouseService);const s=this._linkifier.value=this._register(this._instantiationService.createInstance(M.Linkifier,this.screenElement));this.element.appendChild(t);try{this._onWillOpen.fire(this.element)}catch{}this._renderService.hasRenderer()||this._renderService.setRenderer(this._createRenderer()),this._register(this.onCursorMove((()=>{this._renderService.handleCursorMove(),this._syncTextArea()}))),this._register(this.onResize((()=>this._renderService.handleResize(this.cols,this.rows)))),this._register(this.onBlur((()=>this._renderService.handleBlur()))),this._register(this.onFocus((()=>this._renderService.handleFocus()))),this._viewport=this._register(this._instantiationService.createInstance(o.Viewport,this.element,this.screenElement)),this._register(this._viewport.onRequestScrollLines((e=>{super.scrollLines(e,!1),this.refresh(0,this.rows-1)}))),this._selectionService=this._register(this._instantiationService.createInstance(m.SelectionService,this.element,this.screenElement,s)),this._instantiationService.setService(v.ISelectionService,this._selectionService),this._register(this._selectionService.onRequestScrollLines((e=>this.scrollLines(e.amount,e.suppressScrollEvent)))),this._register(this._selectionService.onSelectionChange((()=>this._onSelectionChange.fire()))),this._register(this._selectionService.onRequestRedraw((e=>this._renderService.handleSelectionChanged(e.start,e.end,e.columnSelectMode)))),this._register(this._selectionService.onLinuxMouseSelection((e=>{this.textarea.value=e,this.textarea.focus(),this.textarea.select()}))),this._register(O.Event.any(this._onScroll.event,this._inputHandler.onScroll)((()=>{this._selectionService.refresh(),this._viewport?.queueSync()}))),this._register(this._instantiationService.createInstance(a.BufferDecorationRenderer,this.screenElement)),this._register((0,I.addDisposableListener)(this.element,"mousedown",(e=>this._selectionService.handleMouseDown(e)))),this.coreMouseService.areMouseEventsActive?(this._selectionService.disable(),this.element.classList.add("enable-mouse-events")):this._selectionService.enable(),this.options.screenReaderMode&&(this._accessibilityManager.value=this._instantiationService.createInstance(k.AccessibilityManager,this)),this._register(this.optionsService.onSpecificOptionChange("screenReaderMode",(e=>this._handleScreenReaderModeOptionChange(e)))),this.options.overviewRuler.width&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(l.OverviewRulerRenderer,this._viewportElement,this.screenElement))),this.optionsService.onSpecificOptionChange("overviewRuler",(e=>{!this._overviewRulerRenderer&&e&&this._viewportElement&&this.screenElement&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(l.OverviewRulerRenderer,this._viewportElement,this.screenElement)))})),this._charSizeService.measure(),this.refresh(0,this.rows-1),this._initGlobal(),this.bindMouse()}_createRenderer(){return this._instantiationService.createInstance(c.DomRenderer,this,this._document,this.element,this.screenElement,this._viewportElement,this._helperContainer,this.linkifier)}bindMouse(){const e=this,t=this.element;function i(t){const i=e._mouseService.getMouseReportCoords(t,e.screenElement);if(!i)return!1;let s,r;switch(t.overrideType||t.type){case"mousemove":r=32,void 0===t.buttons?(s=3,void 0!==t.button&&(s=t.button<3?t.button:3)):s=1&t.buttons?0:4&t.buttons?1:2&t.buttons?2:3;break;case"mouseup":r=0,s=t.button<3?t.button:3;break;case"mousedown":r=1,s=t.button<3?t.button:3;break;case"wheel":if(e._customWheelEventHandler&&!1===e._customWheelEventHandler(t))return!1;const i=t.deltaY;if(0===i)return!1;if(0===e.coreMouseService.consumeWheelEvent(t,e._renderService?.dimensions?.device?.cell?.height,e._coreBrowserService?.dpr))return!1;r=i<0?0:1,s=4;break;default:return!1}return!(void 0===r||void 0===s||s>4)&&e.coreMouseService.triggerMouseEvent({col:i.col,row:i.row,x:i.x,y:i.y,button:s,action:r,ctrl:t.ctrlKey,alt:t.altKey,shift:t.shiftKey})}const s={mouseup:null,wheel:null,mousedrag:null,mousemove:null},r={mouseup:e=>(i(e),e.buttons||(this._document.removeEventListener("mouseup",s.mouseup),s.mousedrag&&this._document.removeEventListener("mousemove",s.mousedrag)),this.cancel(e)),wheel:e=>(i(e),this.cancel(e,!0)),mousedrag:e=>{e.buttons&&i(e)},mousemove:e=>{e.buttons||i(e)}};this._register(this.coreMouseService.onProtocolChange((e=>{e?("debug"===this.optionsService.rawOptions.logLevel&&this._logService.debug("Binding to mouse events:",this.coreMouseService.explainEvents(e)),this.element.classList.add("enable-mouse-events"),this._selectionService.disable()):(this._logService.debug("Unbinding from mouse events."),this.element.classList.remove("enable-mouse-events"),this._selectionService.enable()),8&e?s.mousemove||(t.addEventListener("mousemove",r.mousemove),s.mousemove=r.mousemove):(t.removeEventListener("mousemove",s.mousemove),s.mousemove=null),16&e?s.wheel||(t.addEventListener("wheel",r.wheel,{passive:!1}),s.wheel=r.wheel):(t.removeEventListener("wheel",s.wheel),s.wheel=null),2&e?s.mouseup||(s.mouseup=r.mouseup):(this._document.removeEventListener("mouseup",s.mouseup),s.mouseup=null),4&e?s.mousedrag||(s.mousedrag=r.mousedrag):(this._document.removeEventListener("mousemove",s.mousedrag),s.mousedrag=null)}))),this.coreMouseService.activeProtocol=this.coreMouseService.activeProtocol,this._register((0,I.addDisposableListener)(t,"mousedown",(e=>{if(e.preventDefault(),this.focus(),this.coreMouseService.areMouseEventsActive&&!this._selectionService.shouldForceSelection(e))return i(e),s.mouseup&&this._document.addEventListener("mouseup",s.mouseup),s.mousedrag&&this._document.addEventListener("mousemove",s.mousedrag),this.cancel(e)}))),this._register((0,I.addDisposableListener)(t,"wheel",(t=>{if(!s.wheel){if(this._customWheelEventHandler&&!1===this._customWheelEventHandler(t))return!1;if(!this.buffer.hasScrollback){if(0===t.deltaY)return!1;if(0===e.coreMouseService.consumeWheelEvent(t,e._renderService?.dimensions?.device?.cell?.height,e._coreBrowserService?.dpr))return this.cancel(t,!0);const i=E.C0.ESC+(this.coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(t.deltaY<0?"A":"B");return this.coreService.triggerDataEvent(i,!0),this.cancel(t,!0)}}}),{passive:!1}))}refresh(e,t){this._renderService?.refreshRows(e,t)}updateCursorStyle(e){this._selectionService?.shouldColumnSelect(e)?this.element.classList.add("column-select"):this.element.classList.remove("column-select")}_showCursor(){this.coreService.isCursorInitialized||(this.coreService.isCursorInitialized=!0,this.refresh(this.buffer.y,this.buffer.y))}scrollLines(e,t){this._viewport?this._viewport.scrollLines(e):super.scrollLines(e,t),this.refresh(0,this.rows-1)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){e&&this._viewport?this._viewport.scrollToLine(this.buffer.ybase,!0):this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){const t=e-this._bufferService.buffer.ydisp;0!==t&&this.scrollLines(t)}paste(e){(0,s.paste)(e,this.textarea,this.coreService,this.optionsService)}attachCustomKeyEventHandler(e){this._customKeyEventHandler=e}attachCustomWheelEventHandler(e){this._customWheelEventHandler=e}registerLinkProvider(e){return this._linkProviderService.registerLinkProvider(e)}registerCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");const t=this._characterJoinerService.register(e);return this.refresh(0,this.rows-1),t}deregisterCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");this._characterJoinerService.deregister(e)&&this.refresh(0,this.rows-1)}get markers(){return this.buffer.markers}registerMarker(e){return this.buffer.addMarker(this.buffer.ybase+this.buffer.y+e)}registerDecoration(e){return this._decorationService.registerDecoration(e)}hasSelection(){return!!this._selectionService&&this._selectionService.hasSelection}select(e,t,i){this._selectionService.setSelection(e,t,i)}getSelection(){return this._selectionService?this._selectionService.selectionText:""}getSelectionPosition(){if(this._selectionService&&this._selectionService.hasSelection)return{start:{x:this._selectionService.selectionStart[0],y:this._selectionService.selectionStart[1]},end:{x:this._selectionService.selectionEnd[0],y:this._selectionService.selectionEnd[1]}}}clearSelection(){this._selectionService?.clearSelection()}selectAll(){this._selectionService?.selectAll()}selectLines(e,t){this._selectionService?.selectLines(e,t)}_keyDown(e){if(this._keyDownHandled=!1,this._keyDownSeen=!0,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;const t=this.browser.isMac&&this.options.macOptionIsMeta&&e.altKey;if(!t&&!this._compositionHelper.keydown(e))return this.options.scrollOnUserInput&&this.buffer.ybase!==this.buffer.ydisp&&this.scrollToBottom(!0),!1;t||"Dead"!==e.key&&"AltGraph"!==e.key||(this._unprocessedDeadKey=!0);const i=(0,D.evaluateKeyboardEvent)(e,this.coreService.decPrivateModes.applicationCursorKeys,this.browser.isMac,this.options.macOptionIsMeta);if(this.updateCursorStyle(e),3===i.type||2===i.type){const t=this.rows-1;return this.scrollLines(2===i.type?-t:t),this.cancel(e,!0)}return 1===i.type&&this.selectAll(),!!this._isThirdLevelShift(this.browser,e)||(i.cancel&&this.cancel(e,!0),!i.key||!!(e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&1===e.key.length&&e.key.charCodeAt(0)>=65&&e.key.charCodeAt(0)<=90)||(this._unprocessedDeadKey?(this._unprocessedDeadKey=!1,!0):(i.key!==E.C0.ETX&&i.key!==E.C0.CR||(this.textarea.value=""),this._onKey.fire({key:i.key,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(i.key,!0),!this.optionsService.rawOptions.screenReaderMode||e.altKey||e.ctrlKey?this.cancel(e,!0):void(this._keyDownHandled=!0))))}_isThirdLevelShift(e,t){const i=e.isMac&&!this.options.macOptionIsMeta&&t.altKey&&!t.ctrlKey&&!t.metaKey||e.isWindows&&t.altKey&&t.ctrlKey&&!t.metaKey||e.isWindows&&t.getModifierState("AltGraph");return"keypress"===t.type?i:i&&(!t.keyCode||t.keyCode>47)}_keyUp(e){this._keyDownSeen=!1,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e)||(function(e){return 16===e.keyCode||17===e.keyCode||18===e.keyCode}(e)||this.focus(),this.updateCursorStyle(e),this._keyPressHandled=!1)}_keyPress(e){let t;if(this._keyPressHandled=!1,this._keyDownHandled)return!1;if(this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;if(this.cancel(e),e.charCode)t=e.charCode;else if(null===e.which||void 0===e.which)t=e.keyCode;else{if(0===e.which||0===e.charCode)return!1;t=e.which}return!(!t||(e.altKey||e.ctrlKey||e.metaKey)&&!this._isThirdLevelShift(this.browser,e)||(t=String.fromCharCode(t),this._onKey.fire({key:t,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(t,!0),this._keyPressHandled=!0,this._unprocessedDeadKey=!1,0))}_inputEvent(e){if(e.data&&"insertText"===e.inputType&&(!e.composed||!this._keyDownSeen)&&!this.optionsService.rawOptions.screenReaderMode){if(this._keyPressHandled)return!1;this._unprocessedDeadKey=!1;const t=e.data;return this.coreService.triggerDataEvent(t,!0),this.cancel(e),!0}return!1}resize(e,t){e!==this.cols||t!==this.rows?super.resize(e,t):this._charSizeService&&!this._charSizeService.hasValidSize&&this._charSizeService.measure()}_afterResize(e,t){this._charSizeService?.measure()}clear(){if(0!==this.buffer.ybase||0!==this.buffer.y){this.buffer.clearAllMarkers(),this.buffer.lines.set(0,this.buffer.lines.get(this.buffer.ybase+this.buffer.y)),this.buffer.lines.length=1,this.buffer.ydisp=0,this.buffer.ybase=0,this.buffer.y=0;for(let e=1;e=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Linkifier=void 0;const n=i(7150),o=i(6501),a=i(7098),l=i(802),h=i(7093);let c=class extends n.Disposable{get currentLink(){return this._currentLink}constructor(e,t,i,s,r){super(),this._element=e,this._mouseService=t,this._renderService=i,this._bufferService=s,this._linkProviderService=r,this._linkCacheDisposables=[],this._isMouseOut=!0,this._wasResized=!1,this._activeLine=-1,this._onShowLinkUnderline=this._register(new l.Emitter),this.onShowLinkUnderline=this._onShowLinkUnderline.event,this._onHideLinkUnderline=this._register(new l.Emitter),this.onHideLinkUnderline=this._onHideLinkUnderline.event,this._register((0,n.toDisposable)((()=>{(0,n.dispose)(this._linkCacheDisposables),this._linkCacheDisposables.length=0,this._lastMouseEvent=void 0,this._activeProviderReplies?.clear()}))),this._register(this._bufferService.onResize((()=>{this._clearCurrentLink(),this._wasResized=!0}))),this._register((0,h.addDisposableListener)(this._element,"mouseleave",(()=>{this._isMouseOut=!0,this._clearCurrentLink()}))),this._register((0,h.addDisposableListener)(this._element,"mousemove",this._handleMouseMove.bind(this))),this._register((0,h.addDisposableListener)(this._element,"mousedown",this._handleMouseDown.bind(this))),this._register((0,h.addDisposableListener)(this._element,"mouseup",this._handleMouseUp.bind(this)))}_handleMouseMove(e){this._lastMouseEvent=e;const t=this._positionFromMouseEvent(e,this._element,this._mouseService);if(!t)return;this._isMouseOut=!1;const i=e.composedPath();for(let e=0;e{e?.forEach((e=>{e.link.dispose&&e.link.dispose()}))})),this._activeProviderReplies=new Map,this._activeLine=e.y);let i=!1;for(const[s,r]of this._linkProviderService.linkProviders.entries())if(t){const t=this._activeProviderReplies?.get(s);t&&(i=this._checkLinkProviderResult(s,e,i))}else r.provideLinks(e.y,(t=>{if(this._isMouseOut)return;const r=t?.map((e=>({link:e})));this._activeProviderReplies?.set(s,r),i=this._checkLinkProviderResult(s,e,i),this._activeProviderReplies?.size===this._linkProviderService.linkProviders.length&&this._removeIntersectingLinks(e.y,this._activeProviderReplies)}))}_removeIntersectingLinks(e,t){const i=new Set;for(let s=0;se?this._bufferService.cols:s.link.range.end.x;for(let e=n;e<=o;e++){if(i.has(e)){r.splice(t--,1);break}i.add(e)}}}}_checkLinkProviderResult(e,t,i){if(!this._activeProviderReplies)return i;const s=this._activeProviderReplies.get(e);let r=!1;for(let t=0;tthis._linkAtPosition(e.link,t)));e&&(i=!0,this._handleNewLink(e))}if(this._activeProviderReplies.size===this._linkProviderService.linkProviders.length&&!i)for(let e=0;ethis._linkAtPosition(e.link,t)));if(s){i=!0,this._handleNewLink(s);break}}return i}_handleMouseDown(){this._mouseDownLink=this._currentLink}_handleMouseUp(e){if(!this._currentLink)return;const t=this._positionFromMouseEvent(e,this._element,this._mouseService);var i,s;t&&this._mouseDownLink&&(i=this._mouseDownLink.link,s=this._currentLink.link,i.text===s.text&&i.range.start.x===s.range.start.x&&i.range.start.y===s.range.start.y&&i.range.end.x===s.range.end.x&&i.range.end.y===s.range.end.y)&&this._linkAtPosition(this._currentLink.link,t)&&this._currentLink.link.activate(e,this._currentLink.link.text)}_clearCurrentLink(e,t){this._currentLink&&this._lastMouseEvent&&(!e||!t||this._currentLink.link.range.start.y>=e&&this._currentLink.link.range.end.y<=t)&&(this._linkLeave(this._element,this._currentLink.link,this._lastMouseEvent),this._currentLink=void 0,(0,n.dispose)(this._linkCacheDisposables),this._linkCacheDisposables.length=0)}_handleNewLink(e){if(!this._lastMouseEvent)return;const t=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);t&&this._linkAtPosition(e.link,t)&&(this._currentLink=e,this._currentLink.state={decorations:{underline:void 0===e.link.decorations||e.link.decorations.underline,pointerCursor:void 0===e.link.decorations||e.link.decorations.pointerCursor},isHovered:!0},this._linkHover(this._element,e.link,this._lastMouseEvent),e.link.decorations={},Object.defineProperties(e.link.decorations,{pointerCursor:{get:()=>this._currentLink?.state?.decorations.pointerCursor,set:e=>{this._currentLink?.state&&this._currentLink.state.decorations.pointerCursor!==e&&(this._currentLink.state.decorations.pointerCursor=e,this._currentLink.state.isHovered&&this._element.classList.toggle("xterm-cursor-pointer",e))}},underline:{get:()=>this._currentLink?.state?.decorations.underline,set:t=>{this._currentLink?.state&&this._currentLink?.state?.decorations.underline!==t&&(this._currentLink.state.decorations.underline=t,this._currentLink.state.isHovered&&this._fireUnderlineEvent(e.link,t))}}}),this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange((e=>{if(!this._currentLink)return;const t=0===e.start?0:e.start+1+this._bufferService.buffer.ydisp,i=this._bufferService.buffer.ydisp+1+e.end;if(this._currentLink.link.range.start.y>=t&&this._currentLink.link.range.end.y<=i&&(this._clearCurrentLink(t,i),this._lastMouseEvent)){const e=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);e&&this._askForLink(e,!1)}}))))}_linkHover(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!0,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!0),this._currentLink.state.decorations.pointerCursor&&e.classList.add("xterm-cursor-pointer")),t.hover&&t.hover(i,t.text)}_fireUnderlineEvent(e,t){const i=e.range,s=this._bufferService.buffer.ydisp,r=this._createLinkUnderlineEvent(i.start.x-1,i.start.y-s-1,i.end.x,i.end.y-s-1,void 0);(t?this._onShowLinkUnderline:this._onHideLinkUnderline).fire(r)}_linkLeave(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!1,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!1),this._currentLink.state.decorations.pointerCursor&&e.classList.remove("xterm-cursor-pointer")),t.leave&&t.leave(i,t.text)}_linkAtPosition(e,t){const i=e.range.start.y*this._bufferService.cols+e.range.start.x,s=e.range.end.y*this._bufferService.cols+e.range.end.x,r=t.y*this._bufferService.cols+t.x;return i<=r&&r<=s}_positionFromMouseEvent(e,t,i){const s=i.getCoords(e,t,this._bufferService.cols,this._bufferService.rows);if(s)return{x:s[0],y:s[1]+this._bufferService.buffer.ydisp}}_createLinkUnderlineEvent(e,t,i,s,r){return{x1:e,y1:t,x2:i,y2:s,cols:this._bufferService.cols,fg:r}}};t.Linkifier=c,t.Linkifier=c=s([r(1,a.IMouseService),r(2,a.IRenderService),r(3,o.IBufferService),r(4,a.ILinkProviderService)],c)},7721:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.tooMuchOutput=t.promptLabel=void 0;let i="Terminal input";const s={get:()=>i,set:e=>i=e};t.promptLabel=s;let r="Too much output to announce, navigate to rows manually to read";const n={get:()=>r,set:e=>r=e};t.tooMuchOutput=n},3285:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkProvider=void 0;const n=i(3055),o=i(6501);let a=class{constructor(e,t,i){this._bufferService=e,this._optionsService=t,this._oscLinkService=i}provideLinks(e,t){const i=this._bufferService.buffer.lines.get(e-1);if(!i)return void t(void 0);const s=[],r=this._optionsService.rawOptions.linkHandler,o=new n.CellData,a=i.getTrimmedLength();let h=-1,c=-1,d=!1;for(let t=0;tr?r.activate(e,t,n):l(0,t),hover:(e,t)=>r?.hover?.(e,t,n),leave:(e,t)=>r?.leave?.(e,t,n)})}d=!1,o.hasExtendedAttrs()&&o.extended.urlId?(c=t,h=o.extended.urlId):(c=-1,h=-1)}}t(s)}};function l(e,t){if(confirm(`Do you want to navigate to ${t}?\n\nWARNING: This link could potentially be dangerous`)){const e=window.open();if(e){try{e.opener=null}catch{}e.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}}t.OscLinkProvider=a,t.OscLinkProvider=a=s([r(0,o.IBufferService),r(1,o.IOptionsService),r(2,o.IOscLinkService)],a)},4852:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.RenderDebouncer=void 0,t.RenderDebouncer=class{constructor(e,t){this._renderCallback=e,this._coreBrowserService=t,this._refreshCallbacks=[]}dispose(){this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)}addRefreshCallback(e){return this._refreshCallbacks.push(e),this._animationFrame||(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh()))),this._animationFrame}refresh(e,t,i){this._rowCount=i,e=void 0!==e?e:0,t=void 0!==t?t:this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t,this._animationFrame||(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh())))}_innerRefresh(){if(this._animationFrame=void 0,void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return void this._runRefreshCallbacks();const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t),this._runRefreshCallbacks()}_runRefreshCallbacks(){for(const e of this._refreshCallbacks)e(0);this._refreshCallbacks=[]}}},4292:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.TimeBasedDebouncer=void 0,t.TimeBasedDebouncer=class{constructor(e,t=1e3){this._renderCallback=e,this._debounceThresholdMS=t,this._lastRefreshMs=0,this._additionalRefreshRequested=!1}dispose(){this._refreshTimeoutID&&clearTimeout(this._refreshTimeoutID)}refresh(e,t,i){this._rowCount=i,e=void 0!==e?e:0,t=void 0!==t?t:this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t;const s=performance.now();if(s-this._lastRefreshMs>=this._debounceThresholdMS)this._lastRefreshMs=s,this._innerRefresh();else if(!this._additionalRefreshRequested){const e=s-this._lastRefreshMs,t=this._debounceThresholdMS-e;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout((()=>{this._lastRefreshMs=performance.now(),this._innerRefresh(),this._additionalRefreshRequested=!1,this._refreshTimeoutID=void 0}),t)}}_innerRefresh(){if(void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return;const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t)}}},9302:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_ANSI_COLORS=void 0;const s=i(4103);t.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const e=[s.css.toColor("#2e3436"),s.css.toColor("#cc0000"),s.css.toColor("#4e9a06"),s.css.toColor("#c4a000"),s.css.toColor("#3465a4"),s.css.toColor("#75507b"),s.css.toColor("#06989a"),s.css.toColor("#d3d7cf"),s.css.toColor("#555753"),s.css.toColor("#ef2929"),s.css.toColor("#8ae234"),s.css.toColor("#fce94f"),s.css.toColor("#729fcf"),s.css.toColor("#ad7fa8"),s.css.toColor("#34e2e2"),s.css.toColor("#eeeeec")],t=[0,95,135,175,215,255];for(let i=0;i<216;i++){const r=t[i/36%6|0],n=t[i/6%6|0],o=t[i%6];e.push({css:s.channels.toCss(r,n,o),rgba:s.channels.toRgba(r,n,o)})}for(let t=0;t<24;t++){const i=8+10*t;e.push({css:s.channels.toCss(i,i,i),rgba:s.channels.toRgba(i,i,i)})}return e})())},4017:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Viewport=void 0;const n=i(7098),o=i(7150),a=i(6501),l=i(7093),h=i(8234),c=i(802),d=i(9881);let u=class extends o.Disposable{constructor(e,t,i,s,r,n,a,u){super(),this._bufferService=i,this._optionsService=a,this._renderService=u,this._onRequestScrollLines=this._register(new c.Emitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this._isSyncing=!1,this._isHandlingScroll=!1,this._suppressOnScrollHandler=!1;const _=this._register(new d.Scrollable({forceIntegerValues:!1,smoothScrollDuration:this._optionsService.rawOptions.smoothScrollDuration,scheduleAtNextAnimationFrame:e=>(0,l.scheduleAtNextAnimationFrame)(s.window,e)}));this._register(this._optionsService.onSpecificOptionChange("smoothScrollDuration",(()=>{_.setSmoothScrollDuration(this._optionsService.rawOptions.smoothScrollDuration)}))),this._scrollableElement=this._register(new h.SmoothScrollableElement(t,{vertical:1,horizontal:2,useShadows:!1,mouseWheelSmoothScroll:!0,...this._getChangeOptions()},_)),this._register(this._optionsService.onMultipleOptionChange(["scrollSensitivity","fastScrollSensitivity","overviewRuler"],(()=>this._scrollableElement.updateOptions(this._getChangeOptions())))),this._register(r.onProtocolChange((e=>{this._scrollableElement.updateOptions({handleMouseWheel:!(16&e)})}))),this._scrollableElement.setScrollDimensions({height:0,scrollHeight:0}),this._register(c.Event.runAndSubscribe(n.onChangeColors,(()=>{this._scrollableElement.getDomNode().style.backgroundColor=n.colors.background.css}))),e.appendChild(this._scrollableElement.getDomNode()),this._register((0,o.toDisposable)((()=>this._scrollableElement.getDomNode().remove()))),this._styleElement=s.mainDocument.createElement("style"),t.appendChild(this._styleElement),this._register((0,o.toDisposable)((()=>this._styleElement.remove()))),this._register(c.Event.runAndSubscribe(n.onChangeColors,(()=>{this._styleElement.textContent=[".xterm .xterm-scrollable-element > .scrollbar > .slider {",` background: ${n.colors.scrollbarSliderBackground.css};`,"}",".xterm .xterm-scrollable-element > .scrollbar > .slider:hover {",` background: ${n.colors.scrollbarSliderHoverBackground.css};`,"}",".xterm .xterm-scrollable-element > .scrollbar > .slider.active {",` background: ${n.colors.scrollbarSliderActiveBackground.css};`,"}"].join("\n")}))),this._register(this._bufferService.onResize((()=>this.queueSync()))),this._register(this._bufferService.buffers.onBufferActivate((()=>{this._latestYDisp=void 0,this.queueSync()}))),this._register(this._bufferService.onScroll((()=>this._sync()))),this._register(this._scrollableElement.onScroll((e=>this._handleScroll(e))))}scrollLines(e){const t=this._scrollableElement.getScrollPosition();this._scrollableElement.setScrollPosition({reuseAnimation:!0,scrollTop:t.scrollTop+e*this._renderService.dimensions.css.cell.height})}scrollToLine(e,t){t&&(this._latestYDisp=e),this._scrollableElement.setScrollPosition({reuseAnimation:!t,scrollTop:e*this._renderService.dimensions.css.cell.height})}_getChangeOptions(){return{mouseWheelScrollSensitivity:this._optionsService.rawOptions.scrollSensitivity,fastScrollSensitivity:this._optionsService.rawOptions.fastScrollSensitivity,verticalScrollbarSize:this._optionsService.rawOptions.overviewRuler?.width||14}}queueSync(e){void 0!==e&&(this._latestYDisp=e),void 0===this._queuedAnimationFrame&&(this._queuedAnimationFrame=this._renderService.addRefreshCallback((()=>{this._queuedAnimationFrame=void 0,this._sync(this._latestYDisp)})))}_sync(e=this._bufferService.buffer.ydisp){this._renderService&&!this._isSyncing&&(this._isSyncing=!0,this._suppressOnScrollHandler=!0,this._scrollableElement.setScrollDimensions({height:this._renderService.dimensions.css.canvas.height,scrollHeight:this._renderService.dimensions.css.cell.height*this._bufferService.buffer.lines.length}),this._suppressOnScrollHandler=!1,e!==this._latestYDisp&&this._scrollableElement.setScrollPosition({scrollTop:e*this._renderService.dimensions.css.cell.height}),this._isSyncing=!1)}_handleScroll(e){if(!this._renderService)return;if(this._isHandlingScroll||this._suppressOnScrollHandler)return;this._isHandlingScroll=!0;const t=Math.round(e.scrollTop/this._renderService.dimensions.css.cell.height),i=t-this._bufferService.buffer.ydisp;0!==i&&(this._latestYDisp=t,this._onRequestScrollLines.fire(i)),this._isHandlingScroll=!1}};t.Viewport=u,t.Viewport=u=s([r(2,a.IBufferService),r(3,n.ICoreBrowserService),r(4,a.ICoreMouseService),r(5,n.IThemeService),r(6,a.IOptionsService),r(7,n.IRenderService)],u)},4196:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferDecorationRenderer=void 0;const n=i(7098),o=i(7150),a=i(6501);let l=class extends o.Disposable{constructor(e,t,i,s,r){super(),this._screenElement=e,this._bufferService=t,this._coreBrowserService=i,this._decorationService=s,this._renderService=r,this._decorationElements=new Map,this._altBufferIsActive=!1,this._dimensionsChanged=!1,this._container=document.createElement("div"),this._container.classList.add("xterm-decoration-container"),this._screenElement.appendChild(this._container),this._register(this._renderService.onRenderedViewportChange((()=>this._doRefreshDecorations()))),this._register(this._renderService.onDimensionsChange((()=>{this._dimensionsChanged=!0,this._queueRefresh()}))),this._register(this._coreBrowserService.onDprChange((()=>this._queueRefresh()))),this._register(this._bufferService.buffers.onBufferActivate((()=>{this._altBufferIsActive=this._bufferService.buffer===this._bufferService.buffers.alt}))),this._register(this._decorationService.onDecorationRegistered((()=>this._queueRefresh()))),this._register(this._decorationService.onDecorationRemoved((e=>this._removeDecoration(e)))),this._register((0,o.toDisposable)((()=>{this._container.remove(),this._decorationElements.clear()})))}_queueRefresh(){void 0===this._animationFrame&&(this._animationFrame=this._renderService.addRefreshCallback((()=>{this._doRefreshDecorations(),this._animationFrame=void 0})))}_doRefreshDecorations(){for(const e of this._decorationService.decorations)this._renderDecoration(e);this._dimensionsChanged=!1}_renderDecoration(e){this._refreshStyle(e),this._dimensionsChanged&&this._refreshXPosition(e)}_createElement(e){const t=this._coreBrowserService.mainDocument.createElement("div");t.classList.add("xterm-decoration"),t.classList.toggle("xterm-decoration-top-layer","top"===e?.options?.layer),t.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,t.style.height=(e.options.height||1)*this._renderService.dimensions.css.cell.height+"px",t.style.top=(e.marker.line-this._bufferService.buffers.active.ydisp)*this._renderService.dimensions.css.cell.height+"px",t.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`;const i=e.options.x??0;return i&&i>this._bufferService.cols&&(t.style.display="none"),this._refreshXPosition(e,t),t}_refreshStyle(e){const t=e.marker.line-this._bufferService.buffers.active.ydisp;if(t<0||t>=this._bufferService.rows)e.element&&(e.element.style.display="none",e.onRenderEmitter.fire(e.element));else{let i=this._decorationElements.get(e);i||(i=this._createElement(e),e.element=i,this._decorationElements.set(e,i),this._container.appendChild(i),e.onDispose((()=>{this._decorationElements.delete(e),i.remove()}))),i.style.display=this._altBufferIsActive?"none":"block",this._altBufferIsActive||(i.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,i.style.height=(e.options.height||1)*this._renderService.dimensions.css.cell.height+"px",i.style.top=t*this._renderService.dimensions.css.cell.height+"px",i.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`),e.onRenderEmitter.fire(i)}}_refreshXPosition(e,t=e.element){if(!t)return;const i=e.options.x??0;"right"===(e.options.anchor||"left")?t.style.right=i?i*this._renderService.dimensions.css.cell.width+"px":"":t.style.left=i?i*this._renderService.dimensions.css.cell.width+"px":""}_removeDecoration(e){this._decorationElements.get(e)?.remove(),this._decorationElements.delete(e),e.dispose()}};t.BufferDecorationRenderer=l,t.BufferDecorationRenderer=l=s([r(1,a.IBufferService),r(2,n.ICoreBrowserService),r(3,a.IDecorationService),r(4,n.IRenderService)],l)},957:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ColorZoneStore=void 0,t.ColorZoneStore=class{constructor(){this._zones=[],this._zonePool=[],this._zonePoolIndex=0,this._linePadding={full:0,left:0,center:0,right:0}}get zones(){return this._zonePool.length=Math.min(this._zonePool.length,this._zones.length),this._zones}clear(){this._zones.length=0,this._zonePoolIndex=0}addDecoration(e){if(e.options.overviewRulerOptions){for(const t of this._zones)if(t.color===e.options.overviewRulerOptions.color&&t.position===e.options.overviewRulerOptions.position){if(this._lineIntersectsZone(t,e.marker.line))return;if(this._lineAdjacentToZone(t,e.marker.line,e.options.overviewRulerOptions.position))return void this._addLineToZone(t,e.marker.line)}if(this._zonePoolIndex=e.startBufferLine&&t<=e.endBufferLine}_lineAdjacentToZone(e,t,i){return t>=e.startBufferLine-this._linePadding[i||"full"]&&t<=e.endBufferLine+this._linePadding[i||"full"]}_addLineToZone(e,t){e.startBufferLine=Math.min(e.startBufferLine,t),e.endBufferLine=Math.max(e.endBufferLine,t)}}},9925:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OverviewRulerRenderer=void 0;const n=i(957),o=i(7098),a=i(7150),l=i(6501),h={full:0,left:0,center:0,right:0},c={full:0,left:0,center:0,right:0},d={full:0,left:0,center:0,right:0};let u=class extends a.Disposable{get _width(){return this._optionsService.options.overviewRuler?.width||0}constructor(e,t,i,s,r,o,l,h){super(),this._viewportElement=e,this._screenElement=t,this._bufferService=i,this._decorationService=s,this._renderService=r,this._optionsService=o,this._themeService=l,this._coreBrowserService=h,this._colorZoneStore=new n.ColorZoneStore,this._shouldUpdateDimensions=!0,this._shouldUpdateAnchor=!0,this._lastKnownBufferLength=0,this._canvas=this._coreBrowserService.mainDocument.createElement("canvas"),this._canvas.classList.add("xterm-decoration-overview-ruler"),this._refreshCanvasDimensions(),this._viewportElement.parentElement?.insertBefore(this._canvas,this._viewportElement),this._register((0,a.toDisposable)((()=>this._canvas?.remove())));const c=this._canvas.getContext("2d");if(!c)throw new Error("Ctx cannot be null");this._ctx=c,this._register(this._decorationService.onDecorationRegistered((()=>this._queueRefresh(void 0,!0)))),this._register(this._decorationService.onDecorationRemoved((()=>this._queueRefresh(void 0,!0)))),this._register(this._renderService.onRenderedViewportChange((()=>this._queueRefresh()))),this._register(this._bufferService.buffers.onBufferActivate((()=>{this._canvas.style.display=this._bufferService.buffer===this._bufferService.buffers.alt?"none":"block"}))),this._register(this._bufferService.onScroll((()=>{this._lastKnownBufferLength!==this._bufferService.buffers.normal.lines.length&&(this._refreshDrawHeightConstants(),this._refreshColorZonePadding())}))),this._register(this._renderService.onRender((()=>{this._containerHeight&&this._containerHeight===this._screenElement.clientHeight||(this._queueRefresh(!0),this._containerHeight=this._screenElement.clientHeight)}))),this._register(this._coreBrowserService.onDprChange((()=>this._queueRefresh(!0)))),this._register(this._optionsService.onSpecificOptionChange("overviewRuler",(()=>this._queueRefresh(!0)))),this._register(this._themeService.onChangeColors((()=>this._queueRefresh()))),this._queueRefresh(!0)}_refreshDrawConstants(){const e=Math.floor((this._canvas.width-1)/3),t=Math.ceil((this._canvas.width-1)/3);c.full=this._canvas.width,c.left=e,c.center=t,c.right=e,this._refreshDrawHeightConstants(),d.full=1,d.left=1,d.center=1+c.left,d.right=1+c.left+c.center}_refreshDrawHeightConstants(){h.full=Math.round(2*this._coreBrowserService.dpr);const e=this._canvas.height/this._bufferService.buffer.lines.length,t=Math.round(Math.max(Math.min(e,12),6)*this._coreBrowserService.dpr);h.left=t,h.center=t,h.right=t}_refreshColorZonePadding(){this._colorZoneStore.setPadding({full:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*h.full),left:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*h.left),center:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*h.center),right:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*h.right)}),this._lastKnownBufferLength=this._bufferService.buffers.normal.lines.length}_refreshCanvasDimensions(){this._canvas.style.width=`${this._width}px`,this._canvas.width=Math.round(this._width*this._coreBrowserService.dpr),this._canvas.style.height=`${this._screenElement.clientHeight}px`,this._canvas.height=Math.round(this._screenElement.clientHeight*this._coreBrowserService.dpr),this._refreshDrawConstants(),this._refreshColorZonePadding()}_refreshDecorations(){this._shouldUpdateDimensions&&this._refreshCanvasDimensions(),this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this._colorZoneStore.clear();for(const e of this._decorationService.decorations)this._colorZoneStore.addDecoration(e);this._ctx.lineWidth=1,this._renderRulerOutline();const e=this._colorZoneStore.zones;for(const t of e)"full"!==t.position&&this._renderColorZone(t);for(const t of e)"full"===t.position&&this._renderColorZone(t);this._shouldUpdateDimensions=!1,this._shouldUpdateAnchor=!1}_renderRulerOutline(){this._ctx.fillStyle=this._themeService.colors.overviewRulerBorder.css,this._ctx.fillRect(0,0,1,this._canvas.height),this._optionsService.rawOptions.overviewRuler.showTopBorder&&this._ctx.fillRect(1,0,this._canvas.width-1,1),this._optionsService.rawOptions.overviewRuler.showBottomBorder&&this._ctx.fillRect(1,this._canvas.height-1,this._canvas.width-1,this._canvas.height)}_renderColorZone(e){this._ctx.fillStyle=e.color,this._ctx.fillRect(d[e.position||"full"],Math.round((this._canvas.height-1)*(e.startBufferLine/this._bufferService.buffers.active.lines.length)-h[e.position||"full"]/2),c[e.position||"full"],Math.round((this._canvas.height-1)*((e.endBufferLine-e.startBufferLine)/this._bufferService.buffers.active.lines.length)+h[e.position||"full"]))}_queueRefresh(e,t){this._shouldUpdateDimensions=e||this._shouldUpdateDimensions,this._shouldUpdateAnchor=t||this._shouldUpdateAnchor,void 0===this._animationFrame&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>{this._refreshDecorations(),this._animationFrame=void 0})))}};t.OverviewRulerRenderer=u,t.OverviewRulerRenderer=u=s([r(2,l.IBufferService),r(3,l.IDecorationService),r(4,o.IRenderService),r(5,l.IOptionsService),r(6,o.IThemeService),r(7,o.ICoreBrowserService)],u)},3618:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CompositionHelper=void 0;const n=i(7098),o=i(6501),a=i(3534);let l=class{get isComposing(){return this._isComposing}constructor(e,t,i,s,r,n){this._textarea=e,this._compositionView=t,this._bufferService=i,this._optionsService=s,this._coreService=r,this._renderService=n,this._isComposing=!1,this._isSendingComposition=!1,this._compositionPosition={start:0,end:0},this._dataAlreadySent=""}compositionstart(){this._isComposing=!0,this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent="",this._dataAlreadySent="",this._compositionView.classList.add("active")}compositionupdate(e){this._compositionView.textContent=e.data,this.updateCompositionElements(),setTimeout((()=>{this._compositionPosition.end=this._textarea.value.length}),0)}compositionend(){this._finalizeComposition(!0)}keydown(e){if(this._isComposing||this._isSendingComposition){if(20===e.keyCode||229===e.keyCode)return!1;if(16===e.keyCode||17===e.keyCode||18===e.keyCode)return!1;this._finalizeComposition(!1)}return 229!==e.keyCode||(this._handleAnyTextareaChanges(),!1)}_finalizeComposition(e){if(this._compositionView.classList.remove("active"),this._isComposing=!1,e){const e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0,setTimeout((()=>{if(this._isSendingComposition){let t;this._isSendingComposition=!1,e.start+=this._dataAlreadySent.length,t=this._isComposing?this._textarea.value.substring(e.start,this._compositionPosition.start):this._textarea.value.substring(e.start),t.length>0&&this._coreService.triggerDataEvent(t,!0)}}),0)}else{this._isSendingComposition=!1;const e=this._textarea.value.substring(this._compositionPosition.start,this._compositionPosition.end);this._coreService.triggerDataEvent(e,!0)}}_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.lengththis.updateCompositionElements(!0)),0)}}};t.CompositionHelper=l,t.CompositionHelper=l=s([r(2,o.IBufferService),r(3,o.IOptionsService),r(4,o.ICoreService),r(5,n.IRenderService)],l)},5251:(e,t)=>{function i(e,t,i){const s=i.getBoundingClientRect(),r=e.getComputedStyle(i),n=parseInt(r.getPropertyValue("padding-left")),o=parseInt(r.getPropertyValue("padding-top"));return[t.clientX-s.left-n,t.clientY-s.top-o]}Object.defineProperty(t,"__esModule",{value:!0}),t.getCoordsRelativeToElement=i,t.getCoords=function(e,t,s,r,n,o,a,l,h){if(!o)return;const c=i(e,t,s);return c?(c[0]=Math.ceil((c[0]+(h?a/2:0))/a),c[1]=Math.ceil(c[1]/l),c[0]=Math.min(Math.max(c[0],1),r+(h?1:0)),c[1]=Math.min(Math.max(c[1],1),n),c):void 0}},9686:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.moveToCellSequence=function(e,t,i,s){const o=i.buffer.x,c=i.buffer.y;if(!i.buffer.hasScrollback)return function(e,t,i,s,o,c){return 0===r(t,s,o,c).length?"":h(a(e,t,e,t-n(t,o),!1,o).length,l("D",c))}(o,c,0,t,i,s)+r(c,t,i,s)+function(e,t,i,s,o,c){let d;d=r(t,s,o,c).length>0?s-n(s,o):t;const u=s,_=function(e,t,i,s,o,a){let l;return l=r(i,s,o,a).length>0?s-n(s,o):t,e=i&&le?"D":"C",h(Math.abs(o-e),l(d,s));d=c>t?"D":"C";const u=Math.abs(c-t);return h(function(e,t){return t.cols-e}(c>t?e:o,i)+(u-1)*i.cols+1+((c>t?o:e)-1),l(d,s))};const s=i(3534);function r(e,t,i,s){const r=e-n(e,i),a=t-n(t,i),c=Math.abs(r-a)-function(e,t,i){let s=0;const r=e-n(e,i),a=t-n(t,i);for(let n=0;n=0&&et?"A":"B"}function a(e,t,i,s,r,n){let o=e,a=t,l="";for(;(o!==i||a!==s)&&a>=0&&an.cols-1?(l+=n.buffer.translateBufferLineToString(a,!1,e,o),o=0,e=0,a++):!r&&o<0&&(l+=n.buffer.translateBufferLineToString(a,!1,0,e+1),o=n.cols-1,e=o,a--);return l+n.buffer.translateBufferLineToString(a,!1,e,o)}function l(e,t){const i=t?"O":"[";return s.C0.ESC+i+e}function h(e,t){e=Math.floor(e);let i="";for(let s=0;s=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRenderer=void 0;const n=i(1433),o=i(2744),a=i(9176),l=i(6181),h=i(2274),c=i(7098),d=i(4103),u=i(7150),_=i(6501),f=i(802),p="xterm-dom-renderer-owner-",g="xterm-rows",m="xterm-fg-",v="xterm-bg-",S="xterm-focus",b="xterm-selection";let C=1,y=class extends u.Disposable{constructor(e,t,i,s,r,a,c,d,_,m,v,S,y,w){super(),this._terminal=e,this._document=t,this._element=i,this._screenElement=s,this._viewportElement=r,this._helperContainer=a,this._linkifier2=c,this._charSizeService=_,this._optionsService=m,this._bufferService=v,this._coreService=S,this._coreBrowserService=y,this._themeService=w,this._terminalClass=C++,this._rowElements=[],this._selectionRenderModel=(0,h.createSelectionRenderModel)(),this.onRequestRedraw=this._register(new f.Emitter).event,this._rowContainer=this._document.createElement("div"),this._rowContainer.classList.add(g),this._rowContainer.style.lineHeight="normal",this._rowContainer.setAttribute("aria-hidden","true"),this._refreshRowElements(this._bufferService.cols,this._bufferService.rows),this._selectionContainer=this._document.createElement("div"),this._selectionContainer.classList.add(b),this._selectionContainer.setAttribute("aria-hidden","true"),this.dimensions=(0,l.createRenderDimensions)(),this._updateDimensions(),this._register(this._optionsService.onOptionChange((()=>this._handleOptionsChanged()))),this._register(this._themeService.onChangeColors((e=>this._injectCss(e)))),this._injectCss(this._themeService.colors),this._rowFactory=d.createInstance(n.DomRendererRowFactory,document),this._element.classList.add(p+this._terminalClass),this._screenElement.appendChild(this._rowContainer),this._screenElement.appendChild(this._selectionContainer),this._register(this._linkifier2.onShowLinkUnderline((e=>this._handleLinkHover(e)))),this._register(this._linkifier2.onHideLinkUnderline((e=>this._handleLinkLeave(e)))),this._register((0,u.toDisposable)((()=>{this._element.classList.remove(p+this._terminalClass),this._rowContainer.remove(),this._selectionContainer.remove(),this._widthCache.dispose(),this._themeStyleElement.remove(),this._dimensionsStyleElement.remove()}))),this._widthCache=new o.WidthCache(this._document,this._helperContainer),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}_updateDimensions(){const e=this._coreBrowserService.dpr;this.dimensions.device.char.width=this._charSizeService.width*e,this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*e),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.left=0,this.dimensions.device.char.top=0,this.dimensions.device.canvas.width=this.dimensions.device.cell.width*this._bufferService.cols,this.dimensions.device.canvas.height=this.dimensions.device.cell.height*this._bufferService.rows,this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/e),this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/e),this.dimensions.css.cell.width=this.dimensions.css.canvas.width/this._bufferService.cols,this.dimensions.css.cell.height=this.dimensions.css.canvas.height/this._bufferService.rows;for(const e of this._rowElements)e.style.width=`${this.dimensions.css.canvas.width}px`,e.style.height=`${this.dimensions.css.cell.height}px`,e.style.lineHeight=`${this.dimensions.css.cell.height}px`,e.style.overflow="hidden";this._dimensionsStyleElement||(this._dimensionsStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._dimensionsStyleElement));const t=`${this._terminalSelector} .${g} span { display: inline-block; height: 100%; vertical-align: top;}`;this._dimensionsStyleElement.textContent=t,this._selectionContainer.style.height=this._viewportElement.style.height,this._screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._screenElement.style.height=`${this.dimensions.css.canvas.height}px`}_injectCss(e){this._themeStyleElement||(this._themeStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._themeStyleElement));let t=`${this._terminalSelector} .${g} { pointer-events: none; color: ${e.foreground.css}; font-family: ${this._optionsService.rawOptions.fontFamily}; font-size: ${this._optionsService.rawOptions.fontSize}px; font-kerning: none; white-space: pre}`;t+=`${this._terminalSelector} .${g} .xterm-dim { color: ${d.color.multiplyOpacity(e.foreground,.5).css};}`,t+=`${this._terminalSelector} span:not(.xterm-bold) { font-weight: ${this._optionsService.rawOptions.fontWeight};}${this._terminalSelector} span.xterm-bold { font-weight: ${this._optionsService.rawOptions.fontWeightBold};}${this._terminalSelector} span.xterm-italic { font-style: italic;}`;const i=`blink_underline_${this._terminalClass}`,s=`blink_bar_${this._terminalClass}`,r=`blink_block_${this._terminalClass}`;t+=`@keyframes ${i} { 50% { border-bottom-style: hidden; }}`,t+=`@keyframes ${s} { 50% { box-shadow: none; }}`,t+=`@keyframes ${r} { 0% { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css}; } 50% { background-color: inherit; color: ${e.cursor.css}; }}`,t+=`${this._terminalSelector} .${g}.${S} .xterm-cursor.xterm-cursor-blink.xterm-cursor-underline { animation: ${i} 1s step-end infinite;}${this._terminalSelector} .${g}.${S} .xterm-cursor.xterm-cursor-blink.xterm-cursor-bar { animation: ${s} 1s step-end infinite;}${this._terminalSelector} .${g}.${S} .xterm-cursor.xterm-cursor-blink.xterm-cursor-block { animation: ${r} 1s step-end infinite;}${this._terminalSelector} .${g} .xterm-cursor.xterm-cursor-block { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css};}${this._terminalSelector} .${g} .xterm-cursor.xterm-cursor-block:not(.xterm-cursor-blink) { background-color: ${e.cursor.css} !important; color: ${e.cursorAccent.css} !important;}${this._terminalSelector} .${g} .xterm-cursor.xterm-cursor-outline { outline: 1px solid ${e.cursor.css}; outline-offset: -1px;}${this._terminalSelector} .${g} .xterm-cursor.xterm-cursor-bar { box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${e.cursor.css} inset;}${this._terminalSelector} .${g} .xterm-cursor.xterm-cursor-underline { border-bottom: 1px ${e.cursor.css}; border-bottom-style: solid; height: calc(100% - 1px);}`,t+=`${this._terminalSelector} .${b} { position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;}${this._terminalSelector}.focus .${b} div { position: absolute; background-color: ${e.selectionBackgroundOpaque.css};}${this._terminalSelector} .${b} div { position: absolute; background-color: ${e.selectionInactiveBackgroundOpaque.css};}`;for(const[i,s]of e.ansi.entries())t+=`${this._terminalSelector} .${m}${i} { color: ${s.css}; }${this._terminalSelector} .${m}${i}.xterm-dim { color: ${d.color.multiplyOpacity(s,.5).css}; }${this._terminalSelector} .${v}${i} { background-color: ${s.css}; }`;t+=`${this._terminalSelector} .${m}${a.INVERTED_DEFAULT_COLOR} { color: ${d.color.opaque(e.background).css}; }${this._terminalSelector} .${m}${a.INVERTED_DEFAULT_COLOR}.xterm-dim { color: ${d.color.multiplyOpacity(d.color.opaque(e.background),.5).css}; }${this._terminalSelector} .${v}${a.INVERTED_DEFAULT_COLOR} { background-color: ${e.foreground.css}; }`,this._themeStyleElement.textContent=t}_setDefaultSpacing(){const e=this.dimensions.css.cell.width-this._widthCache.get("W",!1,!1);this._rowContainer.style.letterSpacing=`${e}px`,this._rowFactory.defaultSpacing=e}handleDevicePixelRatioChange(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}_refreshRowElements(e,t){for(let e=this._rowElements.length;e<=t;e++){const e=this._document.createElement("div");this._rowContainer.appendChild(e),this._rowElements.push(e)}for(;this._rowElements.length>t;)this._rowContainer.removeChild(this._rowElements.pop())}handleResize(e,t){this._refreshRowElements(e,t),this._updateDimensions(),this.handleSelectionChanged(this._selectionRenderModel.selectionStart,this._selectionRenderModel.selectionEnd,this._selectionRenderModel.columnSelectMode)}handleCharSizeChanged(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}handleBlur(){this._rowContainer.classList.remove(S),this.renderRows(0,this._bufferService.rows-1)}handleFocus(){this._rowContainer.classList.add(S),this.renderRows(this._bufferService.buffer.y,this._bufferService.buffer.y)}handleSelectionChanged(e,t,i){if(this._selectionContainer.replaceChildren(),this._rowFactory.handleSelectionChanged(e,t,i),this.renderRows(0,this._bufferService.rows-1),!e||!t)return;if(this._selectionRenderModel.update(this._terminal,e,t,i),!this._selectionRenderModel.hasSelection)return;const s=this._selectionRenderModel.viewportStartRow,r=this._selectionRenderModel.viewportEndRow,n=this._selectionRenderModel.viewportCappedStartRow,o=this._selectionRenderModel.viewportCappedEndRow,a=this._document.createDocumentFragment();if(i){const i=e[0]>t[0];a.appendChild(this._createSelectionElement(n,i?t[0]:e[0],i?e[0]:t[0],o-n+1))}else{const i=s===n?e[0]:0,l=n===r?t[0]:this._bufferService.cols;a.appendChild(this._createSelectionElement(n,i,l));const h=o-n-1;if(a.appendChild(this._createSelectionElement(n+1,0,this._bufferService.cols,h)),n!==o){const e=r===o?t[0]:this._bufferService.cols;a.appendChild(this._createSelectionElement(o,0,e))}}this._selectionContainer.appendChild(a)}_createSelectionElement(e,t,i,s=1){const r=this._document.createElement("div"),n=t*this.dimensions.css.cell.width;let o=this.dimensions.css.cell.width*(i-t);return n+o>this.dimensions.css.canvas.width&&(o=this.dimensions.css.canvas.width-n),r.style.height=s*this.dimensions.css.cell.height+"px",r.style.top=e*this.dimensions.css.cell.height+"px",r.style.left=`${n}px`,r.style.width=`${o}px`,r}handleCursorMove(){}_handleOptionsChanged(){this._updateDimensions(),this._injectCss(this._themeService.colors),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}clear(){for(const e of this._rowElements)e.replaceChildren()}renderRows(e,t){const i=this._bufferService.buffer,s=i.ybase+i.y,r=Math.min(i.x,this._bufferService.cols-1),n=this._coreService.decPrivateModes.cursorBlink??this._optionsService.rawOptions.cursorBlink,o=this._coreService.decPrivateModes.cursorStyle??this._optionsService.rawOptions.cursorStyle,a=this._optionsService.rawOptions.cursorInactiveStyle;for(let l=e;l<=t;l++){const e=l+i.ydisp,t=this._rowElements[l],h=i.lines.get(e);if(!t||!h)break;t.replaceChildren(...this._rowFactory.createRow(h,e,e===s,o,a,r,n,this.dimensions.css.cell.width,this._widthCache,-1,-1))}}get _terminalSelector(){return`.${p}${this._terminalClass}`}_handleLinkHover(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!0)}_handleLinkLeave(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!1)}_setCellUnderline(e,t,i,s,r,n){i<0&&(e=0),s<0&&(t=0);const o=this._bufferService.rows-1;i=Math.max(Math.min(i,o),0),s=Math.max(Math.min(s,o),0),r=Math.min(r,this._bufferService.cols);const a=this._bufferService.buffer,l=a.ybase+a.y,h=Math.min(a.x,r-1),c=this._optionsService.rawOptions.cursorBlink,d=this._optionsService.rawOptions.cursorStyle,u=this._optionsService.rawOptions.cursorInactiveStyle;for(let o=i;o<=s;++o){const _=o+a.ydisp,f=this._rowElements[o],p=a.lines.get(_);if(!f||!p)break;f.replaceChildren(...this._rowFactory.createRow(p,_,_===l,d,u,h,c,this.dimensions.css.cell.width,this._widthCache,n?o===i?e:0:-1,n?(o===s?t:r)-1:-1))}}};t.DomRenderer=y,t.DomRenderer=y=s([r(7,_.IInstantiationService),r(8,c.ICharSizeService),r(9,_.IOptionsService),r(10,_.IBufferService),r(11,_.ICoreService),r(12,c.ICoreBrowserService),r(13,c.IThemeService)],y)},1433:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRendererRowFactory=void 0;const n=i(9176),o=i(8938),a=i(3055),l=i(6501),h=i(4103),c=i(7098),d=i(945),u=i(6181),_=i(5451);let f=class{constructor(e,t,i,s,r,n,o){this._document=e,this._characterJoinerService=t,this._optionsService=i,this._coreBrowserService=s,this._coreService=r,this._decorationService=n,this._themeService=o,this._workCell=new a.CellData,this._columnSelectMode=!1,this.defaultSpacing=0}handleSelectionChanged(e,t,i){this._selectionStart=e,this._selectionEnd=t,this._columnSelectMode=i}createRow(e,t,i,s,r,a,l,c,u,f,g){const m=[],v=this._characterJoinerService.getJoinedCharacters(t),S=this._themeService.colors;let b,C=e.getNoBgTrimmedLength();i&&C=O,U=x,F=this._workCell;if(v.length>0&&x===v[0][0]&&N){const s=v.shift(),r=this._isCellInSelection(s[0],t);for(E=s[0]+1;E=s[1],N?(B=!0,F=new d.JoinedCellData(this._workCell,e.translateToString(!0,s[0],s[1]),s[1]-s[0]),U=s[1]-1,C=F.getWidth()):O=s[1]}const W=this._isCellInSelection(x,t),H=i&&x===a,K=P&&x>=f&&x<=g;let z=!1;this._decorationService.forEachDecorationAtCell(x,t,void 0,(e=>{z=!0}));let j=F.getChars()||o.WHITESPACE_CELL_CHAR;if(" "===j&&(F.isUnderline()||F.isOverline())&&(j=" "),M=C*c-u.get(j,F.isBold(),F.isItalic()),b){if(y&&(W&&k||!W&&!k&&F.bg===D)&&(W&&k&&S.selectionForeground||F.fg===L)&&F.extended.ext===R&&K===A&&M===T&&!H&&!B&&!z&&N){F.isInvisible()?w+=o.WHITESPACE_CELL_CHAR:w+=j,y++;continue}y&&(b.textContent=w),b=this._document.createElement("span"),y=0,w=""}else b=this._document.createElement("span");if(D=F.bg,L=F.fg,R=F.extended.ext,A=K,T=M,k=W,B&&a>=x&&a<=U&&(a=x),!this._coreService.isCursorHidden&&H&&this._coreService.isCursorInitialized)if(I.push("xterm-cursor"),this._coreBrowserService.isFocused)l&&I.push("xterm-cursor-blink"),I.push("bar"===s?"xterm-cursor-bar":"underline"===s?"xterm-cursor-underline":"xterm-cursor-block");else if(r)switch(r){case"outline":I.push("xterm-cursor-outline");break;case"block":I.push("xterm-cursor-block");break;case"bar":I.push("xterm-cursor-bar");break;case"underline":I.push("xterm-cursor-underline")}if(F.isBold()&&I.push("xterm-bold"),F.isItalic()&&I.push("xterm-italic"),F.isDim()&&I.push("xterm-dim"),w=F.isInvisible()?o.WHITESPACE_CELL_CHAR:F.getChars()||o.WHITESPACE_CELL_CHAR,F.isUnderline()&&(I.push(`xterm-underline-${F.extended.underlineStyle}`)," "===w&&(w=" "),!F.isUnderlineColorDefault()))if(F.isUnderlineColorRGB())b.style.textDecorationColor=`rgb(${_.AttributeData.toColorRGB(F.getUnderlineColor()).join(",")})`;else{let e=F.getUnderlineColor();this._optionsService.rawOptions.drawBoldTextInBrightColors&&F.isBold()&&e<8&&(e+=8),b.style.textDecorationColor=S.ansi[e].css}F.isOverline()&&(I.push("xterm-overline")," "===w&&(w=" ")),F.isStrikethrough()&&I.push("xterm-strikethrough"),K&&(b.style.textDecoration="underline");let $=F.getFgColor(),V=F.getFgColorMode(),G=F.getBgColor(),q=F.getBgColorMode();const X=!!F.isInverse();if(X){const e=$;$=G,G=e;const t=V;V=q,q=t}let Y,Z,J,Q=!1;switch(this._decorationService.forEachDecorationAtCell(x,t,void 0,(e=>{"top"!==e.options.layer&&Q||(e.backgroundColorRGB&&(q=50331648,G=e.backgroundColorRGB.rgba>>8&16777215,Y=e.backgroundColorRGB),e.foregroundColorRGB&&(V=50331648,$=e.foregroundColorRGB.rgba>>8&16777215,Z=e.foregroundColorRGB),Q="top"===e.options.layer)})),!Q&&W&&(Y=this._coreBrowserService.isFocused?S.selectionBackgroundOpaque:S.selectionInactiveBackgroundOpaque,G=Y.rgba>>8&16777215,q=50331648,Q=!0,S.selectionForeground&&(V=50331648,$=S.selectionForeground.rgba>>8&16777215,Z=S.selectionForeground)),Q&&I.push("xterm-decoration-top"),q){case 16777216:case 33554432:J=S.ansi[G],I.push(`xterm-bg-${G}`);break;case 50331648:J=h.channels.toColor(G>>16,G>>8&255,255&G),this._addStyle(b,`background-color:#${p((G>>>0).toString(16),"0",6)}`);break;default:X?(J=S.foreground,I.push(`xterm-bg-${n.INVERTED_DEFAULT_COLOR}`)):J=S.background}switch(Y||F.isDim()&&(Y=h.color.multiplyOpacity(J,.5)),V){case 16777216:case 33554432:F.isBold()&&$<8&&this._optionsService.rawOptions.drawBoldTextInBrightColors&&($+=8),this._applyMinimumContrast(b,J,S.ansi[$],F,Y,void 0)||I.push(`xterm-fg-${$}`);break;case 50331648:const e=h.channels.toColor($>>16&255,$>>8&255,255&$);this._applyMinimumContrast(b,J,e,F,Y,Z)||this._addStyle(b,`color:#${p($.toString(16),"0",6)}`);break;default:this._applyMinimumContrast(b,J,S.foreground,F,Y,Z)||X&&I.push(`xterm-fg-${n.INVERTED_DEFAULT_COLOR}`)}I.length&&(b.className=I.join(" "),I.length=0),H||B||z||!N?b.textContent=w:y++,M!==this.defaultSpacing&&(b.style.letterSpacing=`${M}px`),m.push(b),x=U}return b&&y&&(b.textContent=w),m}_applyMinimumContrast(e,t,i,s,r,n){if(1===this._optionsService.rawOptions.minimumContrastRatio||(0,u.treatGlyphAsBackgroundColor)(s.getCode()))return!1;const o=this._getContrastCache(s);let a;if(r||n||(a=o.getColor(t.rgba,i.rgba)),void 0===a){const e=this._optionsService.rawOptions.minimumContrastRatio/(s.isDim()?2:1);a=h.color.ensureContrastRatio(r||t,n||i,e),o.setColor((r||t).rgba,(n||i).rgba,a??null)}return!!a&&(this._addStyle(e,`color:${a.css}`),!0)}_getContrastCache(e){return e.isDim()?this._themeService.colors.halfContrastCache:this._themeService.colors.contrastCache}_addStyle(e,t){e.setAttribute("style",`${e.getAttribute("style")||""}${t};`)}_isCellInSelection(e,t){const i=this._selectionStart,s=this._selectionEnd;return!(!i||!s)&&(this._columnSelectMode?i[0]<=s[0]?e>=i[0]&&t>=i[1]&&e=i[1]&&e>=s[0]&&t<=s[1]:t>i[1]&&t=i[0]&&e=i[0])}};function p(e,t,i){for(;e.length{Object.defineProperty(t,"__esModule",{value:!0}),t.WidthCache=void 0,t.WidthCache=class{constructor(e,t){this._flat=new Float32Array(256),this._font="",this._fontSize=0,this._weight="normal",this._weightBold="bold",this._measureElements=[],this._container=e.createElement("div"),this._container.classList.add("xterm-width-cache-measure-container"),this._container.setAttribute("aria-hidden","true"),this._container.style.whiteSpace="pre",this._container.style.fontKerning="none";const i=e.createElement("span");i.classList.add("xterm-char-measure-element");const s=e.createElement("span");s.classList.add("xterm-char-measure-element"),s.style.fontWeight="bold";const r=e.createElement("span");r.classList.add("xterm-char-measure-element"),r.style.fontStyle="italic";const n=e.createElement("span");n.classList.add("xterm-char-measure-element"),n.style.fontWeight="bold",n.style.fontStyle="italic",this._measureElements=[i,s,r,n],this._container.appendChild(i),this._container.appendChild(s),this._container.appendChild(r),this._container.appendChild(n),t.appendChild(this._container),this.clear()}dispose(){this._container.remove(),this._measureElements.length=0,this._holey=void 0}clear(){this._flat.fill(-9999),this._holey=new Map}setFont(e,t,i,s){e===this._font&&t===this._fontSize&&i===this._weight&&s===this._weightBold||(this._font=e,this._fontSize=t,this._weight=i,this._weightBold=s,this._container.style.fontFamily=this._font,this._container.style.fontSize=`${this._fontSize}px`,this._measureElements[0].style.fontWeight=`${i}`,this._measureElements[1].style.fontWeight=`${s}`,this._measureElements[2].style.fontWeight=`${i}`,this._measureElements[3].style.fontWeight=`${s}`,this.clear())}get(e,t,i){let s=0;if(!t&&!i&&1===e.length&&(s=e.charCodeAt(0))<256){if(-9999!==this._flat[s])return this._flat[s];const t=this._measure(e,0);return t>0&&(this._flat[s]=t),t}let r=e;t&&(r+="B"),i&&(r+="I");let n=this._holey.get(r);if(void 0===n){let s=0;t&&(s|=1),i&&(s|=2),n=this._measure(e,s),n>0&&this._holey.set(r,n)}return n}_measure(e,t){const i=this._measureElements[t];return i.textContent=e.repeat(32),i.offsetWidth/32}}},9176:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.INVERTED_DEFAULT_COLOR=void 0,t.INVERTED_DEFAULT_COLOR=257},6181:(e,t)=>{function i(e){return 57508<=e&&e<=57558}function s(e){return e>=128512&&e<=128591||e>=127744&&e<=128511||e>=128640&&e<=128767||e>=9728&&e<=9983||e>=9984&&e<=10175||e>=65024&&e<=65039||e>=129280&&e<=129535||e>=127462&&e<=127487}Object.defineProperty(t,"__esModule",{value:!0}),t.throwIfFalsy=function(e){if(!e)throw new Error("value must not be falsy");return e},t.isPowerlineGlyph=i,t.isRestrictedPowerlineGlyph=function(e){return 57520<=e&&e<=57527},t.isEmoji=s,t.allowRescaling=function(e,t,r,n){return 1===t&&r>Math.ceil(1.5*n)&&void 0!==e&&e>255&&!s(e)&&!i(e)&&!function(e){return 57344<=e&&e<=63743}(e)},t.treatGlyphAsBackgroundColor=function(e){return i(e)||function(e){return 9472<=e&&e<=9631}(e)},t.createRenderDimensions=function(){return{css:{canvas:{width:0,height:0},cell:{width:0,height:0}},device:{canvas:{width:0,height:0},cell:{width:0,height:0},char:{width:0,height:0,left:0,top:0}}}},t.computeNextVariantOffset=function(e,t,i=0){return(e-(2*Math.round(t)-i))%(2*Math.round(t))}},2274:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.createSelectionRenderModel=function(){return new i};class i{constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(e,t,i,s=!1){if(this.selectionStart=t,this.selectionEnd=i,!t||!i||t[0]===i[0]&&t[1]===i[1])return void this.clear();const r=e.buffers.active.ydisp,n=t[1]-r,o=i[1]-r,a=Math.max(n,0),l=Math.min(o,e.rows-1);a>=e.rows||l<0?this.clear():(this.hasSelection=!0,this.columnSelectMode=s,this.viewportStartRow=n,this.viewportEndRow=o,this.viewportCappedStartRow=a,this.viewportCappedEndRow=l,this.startCol=t[0],this.endCol=i[0])}isCellSelected(e,t,i){return!!this.hasSelection&&(i-=e.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?t>=this.startCol&&i>=this.viewportCappedStartRow&&t=this.viewportCappedStartRow&&t>=this.endCol&&i<=this.viewportCappedEndRow:i>this.viewportStartRow&&i=this.startCol&&t=this.startCol)}}},5959:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionModel=void 0,t.SelectionModel=class{constructor(e){this._bufferService=e,this.isSelectAllActive=!1,this.selectionStartLength=0}clearSelection(){this.selectionStart=void 0,this.selectionEnd=void 0,this.isSelectAllActive=!1,this.selectionStartLength=0}get finalSelectionStart(){return this.isSelectAllActive?[0,0]:this.selectionEnd&&this.selectionStart&&this.areSelectionValuesReversed()?this.selectionEnd:this.selectionStart}get finalSelectionEnd(){if(this.isSelectAllActive)return[this._bufferService.cols,this._bufferService.buffer.ybase+this._bufferService.rows-1];if(this.selectionStart){if(!this.selectionEnd||this.areSelectionValuesReversed()){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?e%this._bufferService.cols==0?[this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)-1]:[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[e,this.selectionStart[1]]}if(this.selectionStartLength&&this.selectionEnd[1]===this.selectionStart[1]){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[Math.max(e,this.selectionEnd[0]),this.selectionEnd[1]]}return this.selectionEnd}}areSelectionValuesReversed(){const e=this.selectionStart,t=this.selectionEnd;return!(!e||!t)&&(e[1]>t[1]||e[1]===t[1]&&e[0]>t[0])}handleTrim(e){return this.selectionStart&&(this.selectionStart[1]-=e),this.selectionEnd&&(this.selectionEnd[1]-=e),this.selectionEnd&&this.selectionEnd[1]<0?(this.clearSelection(),!0):(this.selectionStart&&this.selectionStart[1]<0&&(this.selectionStart[1]=0),!1)}}},4792:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharSizeService=void 0;const n=i(6501),o=i(7150),a=i(802);let l=class extends o.Disposable{get hasValidSize(){return this.width>0&&this.height>0}constructor(e,t,i){super(),this._optionsService=i,this.width=0,this.height=0,this._onCharSizeChange=this._register(new a.Emitter),this.onCharSizeChange=this._onCharSizeChange.event;try{this._measureStrategy=this._register(new d(this._optionsService))}catch{this._measureStrategy=this._register(new c(e,t,this._optionsService))}this._register(this._optionsService.onMultipleOptionChange(["fontFamily","fontSize"],(()=>this.measure())))}measure(){const e=this._measureStrategy.measure();e.width===this.width&&e.height===this.height||(this.width=e.width,this.height=e.height,this._onCharSizeChange.fire())}};t.CharSizeService=l,t.CharSizeService=l=s([r(2,n.IOptionsService)],l);class h extends o.Disposable{constructor(){super(...arguments),this._result={width:0,height:0}}_validateAndSet(e,t){void 0!==e&&e>0&&void 0!==t&&t>0&&(this._result.width=e,this._result.height=t)}}class c extends h{constructor(e,t,i){super(),this._document=e,this._parentElement=t,this._optionsService=i,this._measureElement=this._document.createElement("span"),this._measureElement.classList.add("xterm-char-measure-element"),this._measureElement.textContent="W".repeat(32),this._measureElement.setAttribute("aria-hidden","true"),this._measureElement.style.whiteSpace="pre",this._measureElement.style.fontKerning="none",this._parentElement.appendChild(this._measureElement)}measure(){return this._measureElement.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._measureElement.style.fontSize=`${this._optionsService.rawOptions.fontSize}px`,this._validateAndSet(Number(this._measureElement.offsetWidth)/32,Number(this._measureElement.offsetHeight)),this._result}}class d extends h{constructor(e){super(),this._optionsService=e,this._canvas=new OffscreenCanvas(100,100),this._ctx=this._canvas.getContext("2d");const t=this._ctx.measureText("W");if(!("width"in t&&"fontBoundingBoxAscent"in t&&"fontBoundingBoxDescent"in t))throw new Error("Required font metrics not supported")}measure(){this._ctx.font=`${this._optionsService.rawOptions.fontSize}px ${this._optionsService.rawOptions.fontFamily}`;const e=this._ctx.measureText("W");return this._validateAndSet(e.width,e.fontBoundingBoxAscent+e.fontBoundingBoxDescent),this._result}}},945:function(e,t,i){var s,r=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},n=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharacterJoinerService=t.JoinedCellData=void 0;const o=i(5451),a=i(8938),l=i(3055),h=i(6501);class c extends o.AttributeData{constructor(e,t,i){super(),this.content=0,this.combinedData="",this.fg=e.fg,this.bg=e.bg,this.combinedData=t,this._width=i}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(e){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.JoinedCellData=c;let d=s=class{constructor(e){this._bufferService=e,this._characterJoiners=[],this._nextCharacterJoinerId=0,this._workCell=new l.CellData}register(e){const t={id:this._nextCharacterJoinerId++,handler:e};return this._characterJoiners.push(t),t.id}deregister(e){for(let t=0;t1){const e=this._getJoinedRanges(s,o,n,t,r);for(let t=0;t1){const e=this._getJoinedRanges(s,o,n,t,r);for(let t=0;t{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreBrowserService=void 0;const s=i(802),r=i(7093),n=i(7150);class o extends n.Disposable{constructor(e,t,i){super(),this._textarea=e,this._window=t,this.mainDocument=i,this._isFocused=!1,this._cachedIsFocused=void 0,this._screenDprMonitor=this._register(new a(this._window)),this._onDprChange=this._register(new s.Emitter),this.onDprChange=this._onDprChange.event,this._onWindowChange=this._register(new s.Emitter),this.onWindowChange=this._onWindowChange.event,this._register(this.onWindowChange((e=>this._screenDprMonitor.setWindow(e)))),this._register(s.Event.forward(this._screenDprMonitor.onDprChange,this._onDprChange)),this._register((0,r.addDisposableListener)(this._textarea,"focus",(()=>this._isFocused=!0))),this._register((0,r.addDisposableListener)(this._textarea,"blur",(()=>this._isFocused=!1)))}get window(){return this._window}set window(e){this._window!==e&&(this._window=e,this._onWindowChange.fire(this._window))}get dpr(){return this.window.devicePixelRatio}get isFocused(){return void 0===this._cachedIsFocused&&(this._cachedIsFocused=this._isFocused&&this._textarea.ownerDocument.hasFocus(),queueMicrotask((()=>this._cachedIsFocused=void 0))),this._cachedIsFocused}}t.CoreBrowserService=o;class a extends n.Disposable{constructor(e){super(),this._parentWindow=e,this._windowResizeListener=this._register(new n.MutableDisposable),this._onDprChange=this._register(new s.Emitter),this.onDprChange=this._onDprChange.event,this._outerListener=()=>this._setDprAndFireIfDiffers(),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._updateDpr(),this._setWindowResizeListener(),this._register((0,n.toDisposable)((()=>this.clearListener())))}setWindow(e){this._parentWindow=e,this._setWindowResizeListener(),this._setDprAndFireIfDiffers()}_setWindowResizeListener(){this._windowResizeListener.value=(0,r.addDisposableListener)(this._parentWindow,"resize",(()=>this._setDprAndFireIfDiffers()))}_setDprAndFireIfDiffers(){this._parentWindow.devicePixelRatio!==this._currentDevicePixelRatio&&this._onDprChange.fire(this._parentWindow.devicePixelRatio),this._updateDpr()}_updateDpr(){this._outerListener&&(this._resolutionMediaMatchList?.removeListener(this._outerListener),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._resolutionMediaMatchList=this._parentWindow.matchMedia(`screen and (resolution: ${this._parentWindow.devicePixelRatio}dppx)`),this._resolutionMediaMatchList.addListener(this._outerListener))}clearListener(){this._resolutionMediaMatchList&&this._outerListener&&(this._resolutionMediaMatchList.removeListener(this._outerListener),this._resolutionMediaMatchList=void 0,this._outerListener=void 0)}}},9820:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.LinkProviderService=void 0;const s=i(7150);class r extends s.Disposable{constructor(){super(),this.linkProviders=[],this._register((0,s.toDisposable)((()=>this.linkProviders.length=0)))}registerLinkProvider(e){return this.linkProviders.push(e),{dispose:()=>{const t=this.linkProviders.indexOf(e);-1!==t&&this.linkProviders.splice(t,1)}}}}t.LinkProviderService=r},9784:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.MouseService=void 0;const n=i(7098),o=i(5251);let a=class{constructor(e,t){this._renderService=e,this._charSizeService=t}getCoords(e,t,i,s,r){return(0,o.getCoords)(window,e,t,i,s,this._charSizeService.hasValidSize,this._renderService.dimensions.css.cell.width,this._renderService.dimensions.css.cell.height,r)}getMouseReportCoords(e,t){const i=(0,o.getCoordsRelativeToElement)(window,e,t);if(this._charSizeService.hasValidSize)return i[0]=Math.min(Math.max(i[0],0),this._renderService.dimensions.css.canvas.width-1),i[1]=Math.min(Math.max(i[1],0),this._renderService.dimensions.css.canvas.height-1),{col:Math.floor(i[0]/this._renderService.dimensions.css.cell.width),row:Math.floor(i[1]/this._renderService.dimensions.css.cell.height),x:Math.floor(i[0]),y:Math.floor(i[1])}}};t.MouseService=a,t.MouseService=a=s([r(0,n.IRenderService),r(1,n.ICharSizeService)],a)},5783:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.RenderService=void 0;const n=i(4852),o=i(7098),a=i(7150),l=i(6168),h=i(6501),c=i(802);let d=class extends a.Disposable{get dimensions(){return this._renderer.value.dimensions}constructor(e,t,i,s,r,o,h,d,_){super(),this._rowCount=e,this._optionsService=i,this._charSizeService=s,this._coreService=r,this._coreBrowserService=d,this._renderer=this._register(new a.MutableDisposable),this._pausedResizeTask=new l.DebouncedIdleTask,this._observerDisposable=this._register(new a.MutableDisposable),this._isPaused=!1,this._needsFullRefresh=!1,this._isNextRenderRedrawOnly=!0,this._needsSelectionRefresh=!1,this._canvasWidth=0,this._canvasHeight=0,this._selectionState={start:void 0,end:void 0,columnSelectMode:!1},this._onDimensionsChange=this._register(new c.Emitter),this.onDimensionsChange=this._onDimensionsChange.event,this._onRenderedViewportChange=this._register(new c.Emitter),this.onRenderedViewportChange=this._onRenderedViewportChange.event,this._onRender=this._register(new c.Emitter),this.onRender=this._onRender.event,this._onRefreshRequest=this._register(new c.Emitter),this.onRefreshRequest=this._onRefreshRequest.event,this._renderDebouncer=new n.RenderDebouncer(((e,t)=>this._renderRows(e,t)),this._coreBrowserService),this._register(this._renderDebouncer),this._syncOutputHandler=new u(this._coreBrowserService,this._coreService,(()=>this._fullRefresh())),this._register((0,a.toDisposable)((()=>this._syncOutputHandler.dispose()))),this._register(this._coreBrowserService.onDprChange((()=>this.handleDevicePixelRatioChange()))),this._register(h.onResize((()=>this._fullRefresh()))),this._register(h.buffers.onBufferActivate((()=>this._renderer.value?.clear()))),this._register(this._optionsService.onOptionChange((()=>this._handleOptionsChanged()))),this._register(this._charSizeService.onCharSizeChange((()=>this.handleCharSizeChanged()))),this._register(o.onDecorationRegistered((()=>this._fullRefresh()))),this._register(o.onDecorationRemoved((()=>this._fullRefresh()))),this._register(this._optionsService.onMultipleOptionChange(["customGlyphs","drawBoldTextInBrightColors","letterSpacing","lineHeight","fontFamily","fontSize","fontWeight","fontWeightBold","minimumContrastRatio","rescaleOverlappingGlyphs"],(()=>{this.clear(),this.handleResize(h.cols,h.rows),this._fullRefresh()}))),this._register(this._optionsService.onMultipleOptionChange(["cursorBlink","cursorStyle"],(()=>this.refreshRows(h.buffer.y,h.buffer.y,!0)))),this._register(_.onChangeColors((()=>this._fullRefresh()))),this._registerIntersectionObserver(this._coreBrowserService.window,t),this._register(this._coreBrowserService.onWindowChange((e=>this._registerIntersectionObserver(e,t))))}_registerIntersectionObserver(e,t){if("IntersectionObserver"in e){const i=new e.IntersectionObserver((e=>this._handleIntersectionChange(e[e.length-1])),{threshold:0});i.observe(t),this._observerDisposable.value=(0,a.toDisposable)((()=>i.disconnect()))}}_handleIntersectionChange(e){this._isPaused=void 0===e.isIntersecting?0===e.intersectionRatio:!e.isIntersecting,this._isPaused||this._charSizeService.hasValidSize||this._charSizeService.measure(),!this._isPaused&&this._needsFullRefresh&&(this._pausedResizeTask.flush(),this.refreshRows(0,this._rowCount-1),this._needsFullRefresh=!1)}refreshRows(e,t,i=!1){if(this._isPaused)return void(this._needsFullRefresh=!0);if(this._coreService.decPrivateModes.synchronizedOutput)return void this._syncOutputHandler.bufferRows(e,t);const s=this._syncOutputHandler.flush();s&&(e=Math.min(e,s.start),t=Math.max(t,s.end)),i||(this._isNextRenderRedrawOnly=!1),this._renderDebouncer.refresh(e,t,this._rowCount)}_renderRows(e,t){this._renderer.value&&(this._coreService.decPrivateModes.synchronizedOutput?this._syncOutputHandler.bufferRows(e,t):(e=Math.min(e,this._rowCount-1),t=Math.min(t,this._rowCount-1),this._renderer.value.renderRows(e,t),this._needsSelectionRefresh&&(this._renderer.value.handleSelectionChanged(this._selectionState.start,this._selectionState.end,this._selectionState.columnSelectMode),this._needsSelectionRefresh=!1),this._isNextRenderRedrawOnly||this._onRenderedViewportChange.fire({start:e,end:t}),this._onRender.fire({start:e,end:t}),this._isNextRenderRedrawOnly=!0))}resize(e,t){this._rowCount=t,this._fireOnCanvasResize()}_handleOptionsChanged(){this._renderer.value&&(this.refreshRows(0,this._rowCount-1),this._fireOnCanvasResize())}_fireOnCanvasResize(){this._renderer.value&&(this._renderer.value.dimensions.css.canvas.width===this._canvasWidth&&this._renderer.value.dimensions.css.canvas.height===this._canvasHeight||this._onDimensionsChange.fire(this._renderer.value.dimensions))}hasRenderer(){return!!this._renderer.value}setRenderer(e){this._renderer.value=e,this._renderer.value&&(this._renderer.value.onRequestRedraw((e=>this.refreshRows(e.start,e.end,!0))),this._needsSelectionRefresh=!0,this._fullRefresh())}addRefreshCallback(e){return this._renderDebouncer.addRefreshCallback(e)}_fullRefresh(){this._isPaused?this._needsFullRefresh=!0:this.refreshRows(0,this._rowCount-1)}clearTextureAtlas(){this._renderer.value&&(this._renderer.value.clearTextureAtlas?.(),this._fullRefresh())}handleDevicePixelRatioChange(){this._charSizeService.measure(),this._renderer.value&&(this._renderer.value.handleDevicePixelRatioChange(),this.refreshRows(0,this._rowCount-1))}handleResize(e,t){this._renderer.value&&(this._isPaused?this._pausedResizeTask.set((()=>this._renderer.value?.handleResize(e,t))):this._renderer.value.handleResize(e,t),this._fullRefresh())}handleCharSizeChanged(){this._renderer.value?.handleCharSizeChanged()}handleBlur(){this._renderer.value?.handleBlur()}handleFocus(){this._renderer.value?.handleFocus()}handleSelectionChanged(e,t,i){this._selectionState.start=e,this._selectionState.end=t,this._selectionState.columnSelectMode=i,this._renderer.value?.handleSelectionChanged(e,t,i)}handleCursorMove(){this._renderer.value?.handleCursorMove()}clear(){this._renderer.value?.clear()}};t.RenderService=d,t.RenderService=d=s([r(2,h.IOptionsService),r(3,o.ICharSizeService),r(4,h.ICoreService),r(5,h.IDecorationService),r(6,h.IBufferService),r(7,o.ICoreBrowserService),r(8,o.IThemeService)],d);class u{constructor(e,t,i){this._coreBrowserService=e,this._coreService=t,this._onTimeout=i,this._start=0,this._end=0,this._isBuffering=!1}bufferRows(e,t){this._isBuffering?(this._start=Math.min(this._start,e),this._end=Math.max(this._end,t)):(this._start=e,this._end=t,this._isBuffering=!0),void 0===this._timeout&&(this._timeout=this._coreBrowserService.window.setTimeout((()=>{this._timeout=void 0,this._coreService.decPrivateModes.synchronizedOutput=!1,this._onTimeout()}),1e3))}flush(){if(void 0!==this._timeout&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0),!this._isBuffering)return;const e={start:this._start,end:this._end};return this._isBuffering=!1,e}dispose(){void 0!==this._timeout&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0)}}},2079:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionService=void 0;const n=i(5251),o=i(9686),a=i(5959),l=i(7098),h=i(7150),c=i(701),d=i(9384),u=i(3055),_=i(6501),f=i(802),p=String.fromCharCode(160),g=new RegExp(p,"g");let m=class extends h.Disposable{constructor(e,t,i,s,r,n,o,l,c){super(),this._element=e,this._screenElement=t,this._linkifier=i,this._bufferService=s,this._coreService=r,this._mouseService=n,this._optionsService=o,this._renderService=l,this._coreBrowserService=c,this._dragScrollAmount=0,this._enabled=!0,this._workCell=new u.CellData,this._mouseDownTimeStamp=0,this._oldHasSelection=!1,this._oldSelectionStart=void 0,this._oldSelectionEnd=void 0,this._onLinuxMouseSelection=this._register(new f.Emitter),this.onLinuxMouseSelection=this._onLinuxMouseSelection.event,this._onRedrawRequest=this._register(new f.Emitter),this.onRequestRedraw=this._onRedrawRequest.event,this._onSelectionChange=this._register(new f.Emitter),this.onSelectionChange=this._onSelectionChange.event,this._onRequestScrollLines=this._register(new f.Emitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this._mouseMoveListener=e=>this._handleMouseMove(e),this._mouseUpListener=e=>this._handleMouseUp(e),this._coreService.onUserInput((()=>{this.hasSelection&&this.clearSelection()})),this._trimListener=this._bufferService.buffer.lines.onTrim((e=>this._handleTrim(e))),this._register(this._bufferService.buffers.onBufferActivate((e=>this._handleBufferActivate(e)))),this.enable(),this._model=new a.SelectionModel(this._bufferService),this._activeSelectionMode=0,this._register((0,h.toDisposable)((()=>{this._removeMouseDownListeners()}))),this._register(this._bufferService.onResize((e=>{e.rowsChanged&&this.clearSelection()})))}reset(){this.clearSelection()}disable(){this.clearSelection(),this._enabled=!1}enable(){this._enabled=!0}get selectionStart(){return this._model.finalSelectionStart}get selectionEnd(){return this._model.finalSelectionEnd}get hasSelection(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;return!(!e||!t||e[0]===t[0]&&e[1]===t[1])}get selectionText(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;if(!e||!t)return"";const i=this._bufferService.buffer,s=[];if(3===this._activeSelectionMode){if(e[0]===t[0])return"";const r=e[0]e.replace(g," "))).join(c.isWindows?"\r\n":"\n")}clearSelection(){this._model.clearSelection(),this._removeMouseDownListeners(),this.refresh(),this._onSelectionChange.fire()}refresh(e){this._refreshAnimationFrame||(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._refresh()))),c.isLinux&&e&&this.selectionText.length&&this._onLinuxMouseSelection.fire(this.selectionText)}_refresh(){this._refreshAnimationFrame=void 0,this._onRedrawRequest.fire({start:this._model.finalSelectionStart,end:this._model.finalSelectionEnd,columnSelectMode:3===this._activeSelectionMode})}_isClickInSelection(e){const t=this._getMouseBufferCoords(e),i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!!(i&&s&&t)&&this._areCoordsInSelection(t,i,s)}isCellInSelection(e,t){const i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!(!i||!s)&&this._areCoordsInSelection([e,t],i,s)}_areCoordsInSelection(e,t,i){return e[1]>t[1]&&e[1]=t[0]&&e[0]=t[0]}_selectWordAtCursor(e,t){const i=this._linkifier.currentLink?.link?.range;if(i)return this._model.selectionStart=[i.start.x-1,i.start.y-1],this._model.selectionStartLength=(0,d.getRangeLength)(i,this._bufferService.cols),this._model.selectionEnd=void 0,!0;const s=this._getMouseBufferCoords(e);return!!s&&(this._selectWordAt(s,t),this._model.selectionEnd=void 0,!0)}selectAll(){this._model.isSelectAllActive=!0,this.refresh(),this._onSelectionChange.fire()}selectLines(e,t){this._model.clearSelection(),e=Math.max(e,0),t=Math.min(t,this._bufferService.buffer.lines.length-1),this._model.selectionStart=[0,e],this._model.selectionEnd=[this._bufferService.cols,t],this.refresh(),this._onSelectionChange.fire()}_handleTrim(e){this._model.handleTrim(e)&&this.refresh()}_getMouseBufferCoords(e){const t=this._mouseService.getCoords(e,this._screenElement,this._bufferService.cols,this._bufferService.rows,!0);if(t)return t[0]--,t[1]--,t[1]+=this._bufferService.buffer.ydisp,t}_getMouseEventScrollAmount(e){let t=(0,n.getCoordsRelativeToElement)(this._coreBrowserService.window,e,this._screenElement)[1];const i=this._renderService.dimensions.css.canvas.height;return t>=0&&t<=i?0:(t>i&&(t-=i),t=Math.min(Math.max(t,-50),50),t/=50,t/Math.abs(t)+Math.round(14*t))}shouldForceSelection(e){return c.isMac?e.altKey&&this._optionsService.rawOptions.macOptionClickForcesSelection:e.shiftKey}handleMouseDown(e){if(this._mouseDownTimeStamp=e.timeStamp,(2!==e.button||!this.hasSelection)&&0===e.button){if(!this._enabled){if(!this.shouldForceSelection(e))return;e.stopPropagation()}e.preventDefault(),this._dragScrollAmount=0,this._enabled&&e.shiftKey?this._handleIncrementalClick(e):1===e.detail?this._handleSingleClick(e):2===e.detail?this._handleDoubleClick(e):3===e.detail&&this._handleTripleClick(e),this._addMouseDownListeners(),this.refresh(!0)}}_addMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.addEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.addEventListener("mouseup",this._mouseUpListener)),this._dragScrollIntervalTimer=this._coreBrowserService.window.setInterval((()=>this._dragScroll()),50)}_removeMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.removeEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.removeEventListener("mouseup",this._mouseUpListener)),this._coreBrowserService.window.clearInterval(this._dragScrollIntervalTimer),this._dragScrollIntervalTimer=void 0}_handleIncrementalClick(e){this._model.selectionStart&&(this._model.selectionEnd=this._getMouseBufferCoords(e))}_handleSingleClick(e){if(this._model.selectionStartLength=0,this._model.isSelectAllActive=!1,this._activeSelectionMode=this.shouldColumnSelect(e)?3:0,this._model.selectionStart=this._getMouseBufferCoords(e),!this._model.selectionStart)return;this._model.selectionEnd=void 0;const t=this._bufferService.buffer.lines.get(this._model.selectionStart[1]);t&&t.length!==this._model.selectionStart[0]&&0===t.hasWidth(this._model.selectionStart[0])&&this._model.selectionStart[0]++}_handleDoubleClick(e){this._selectWordAtCursor(e,!0)&&(this._activeSelectionMode=1)}_handleTripleClick(e){const t=this._getMouseBufferCoords(e);t&&(this._activeSelectionMode=2,this._selectLineAt(t[1]))}shouldColumnSelect(e){return e.altKey&&!(c.isMac&&this._optionsService.rawOptions.macOptionClickForcesSelection)}_handleMouseMove(e){if(e.stopImmediatePropagation(),!this._model.selectionStart)return;const t=this._model.selectionEnd?[this._model.selectionEnd[0],this._model.selectionEnd[1]]:null;if(this._model.selectionEnd=this._getMouseBufferCoords(e),!this._model.selectionEnd)return void this.refresh(!0);2===this._activeSelectionMode?this._model.selectionEnd[1]0?this._model.selectionEnd[0]=this._bufferService.cols:this._dragScrollAmount<0&&(this._model.selectionEnd[0]=0));const i=this._bufferService.buffer;if(this._model.selectionEnd[1]0?(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=this._bufferService.cols),this._model.selectionEnd[1]=Math.min(e.ydisp+this._bufferService.rows,e.lines.length-1)):(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=0),this._model.selectionEnd[1]=e.ydisp),this.refresh()}}_handleMouseUp(e){const t=e.timeStamp-this._mouseDownTimeStamp;if(this._removeMouseDownListeners(),this.selectionText.length<=1&&t<500&&e.altKey&&this._optionsService.rawOptions.altClickMovesCursor){if(this._bufferService.buffer.ybase===this._bufferService.buffer.ydisp){const t=this._mouseService.getCoords(e,this._element,this._bufferService.cols,this._bufferService.rows,!1);if(t&&void 0!==t[0]&&void 0!==t[1]){const e=(0,o.moveToCellSequence)(t[0]-1,t[1]-1,this._bufferService,this._coreService.decPrivateModes.applicationCursorKeys);this._coreService.triggerDataEvent(e,!0)}}}else this._fireEventIfSelectionChanged()}_fireEventIfSelectionChanged(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd,i=!(!e||!t||e[0]===t[0]&&e[1]===t[1]);i?e&&t&&(this._oldSelectionStart&&this._oldSelectionEnd&&e[0]===this._oldSelectionStart[0]&&e[1]===this._oldSelectionStart[1]&&t[0]===this._oldSelectionEnd[0]&&t[1]===this._oldSelectionEnd[1]||this._fireOnSelectionChange(e,t,i)):this._oldHasSelection&&this._fireOnSelectionChange(e,t,i)}_fireOnSelectionChange(e,t,i){this._oldSelectionStart=e,this._oldSelectionEnd=t,this._oldHasSelection=i,this._onSelectionChange.fire()}_handleBufferActivate(e){this.clearSelection(),this._trimListener.dispose(),this._trimListener=e.activeBuffer.lines.onTrim((e=>this._handleTrim(e)))}_convertViewportColToCharacterIndex(e,t){let i=t;for(let s=0;t>=s;s++){const r=e.loadCell(s,this._workCell).getChars().length;0===this._workCell.getWidth()?i--:r>1&&t!==s&&(i+=r-1)}return i}setSelection(e,t,i){this._model.clearSelection(),this._removeMouseDownListeners(),this._model.selectionStart=[e,t],this._model.selectionStartLength=i,this.refresh(),this._fireEventIfSelectionChanged()}rightClickSelect(e){this._isClickInSelection(e)||(this._selectWordAtCursor(e,!1)&&this.refresh(!0),this._fireEventIfSelectionChanged())}_getWordAt(e,t,i=!0,s=!0){if(e[0]>=this._bufferService.cols)return;const r=this._bufferService.buffer,n=r.lines.get(e[1]);if(!n)return;const o=r.translateBufferLineToString(e[1],!1);let a=this._convertViewportColToCharacterIndex(n,e[0]),l=a;const h=e[0]-a;let c=0,d=0,u=0,_=0;if(" "===o.charAt(a)){for(;a>0&&" "===o.charAt(a-1);)a--;for(;l1&&(_+=s-1,l+=s-1);t>0&&a>0&&!this._isCharWordSeparator(n.loadCell(t-1,this._workCell));){n.loadCell(t-1,this._workCell);const e=this._workCell.getChars().length;0===this._workCell.getWidth()?(c++,t--):e>1&&(u+=e-1,a-=e-1),a--,t--}for(;i1&&(_+=e-1,l+=e-1),l++,i++}}l++;let f=a+h-c+u,p=Math.min(this._bufferService.cols,l-a+c+d-u-_);if(t||""!==o.slice(a,l).trim()){if(i&&0===f&&32!==n.getCodePoint(0)){const t=r.lines.get(e[1]-1);if(t&&n.isWrapped&&32!==t.getCodePoint(this._bufferService.cols-1)){const t=this._getWordAt([this._bufferService.cols-1,e[1]-1],!1,!0,!1);if(t){const e=this._bufferService.cols-t.start;f-=e,p+=e}}}if(s&&f+p===this._bufferService.cols&&32!==n.getCodePoint(this._bufferService.cols-1)){const t=r.lines.get(e[1]+1);if(t?.isWrapped&&32!==t.getCodePoint(0)){const t=this._getWordAt([0,e[1]+1],!1,!1,!0);t&&(p+=t.length)}}return{start:f,length:p}}}_selectWordAt(e,t){const i=this._getWordAt(e,t);if(i){for(;i.start<0;)i.start+=this._bufferService.cols,e[1]--;this._model.selectionStart=[i.start,e[1]],this._model.selectionStartLength=i.length}}_selectToWordAt(e){const t=this._getWordAt(e,!0);if(t){let i=e[1];for(;t.start<0;)t.start+=this._bufferService.cols,i--;if(!this._model.areSelectionValuesReversed())for(;t.start+t.length>this._bufferService.cols;)t.length-=this._bufferService.cols,i++;this._model.selectionEnd=[this._model.areSelectionValuesReversed()?t.start:t.start+t.length,i]}}_isCharWordSeparator(e){return 0!==e.getWidth()&&this._optionsService.rawOptions.wordSeparator.indexOf(e.getChars())>=0}_selectLineAt(e){const t=this._bufferService.buffer.getWrappedRangeForLine(e),i={start:{x:0,y:t.first},end:{x:this._bufferService.cols-1,y:t.last}};this._model.selectionStart=[0,t.first],this._model.selectionEnd=void 0,this._model.selectionStartLength=(0,d.getRangeLength)(i,this._bufferService.cols)}};t.SelectionService=m,t.SelectionService=m=s([r(3,_.IBufferService),r(4,_.ICoreService),r(5,l.IMouseService),r(6,_.IOptionsService),r(7,l.IRenderService),r(8,l.ICoreBrowserService)],m)},7098:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ILinkProviderService=t.IThemeService=t.ICharacterJoinerService=t.ISelectionService=t.IRenderService=t.IMouseService=t.ICoreBrowserService=t.ICharSizeService=void 0;const s=i(6201);t.ICharSizeService=(0,s.createDecorator)("CharSizeService"),t.ICoreBrowserService=(0,s.createDecorator)("CoreBrowserService"),t.IMouseService=(0,s.createDecorator)("MouseService"),t.IRenderService=(0,s.createDecorator)("RenderService"),t.ISelectionService=(0,s.createDecorator)("SelectionService"),t.ICharacterJoinerService=(0,s.createDecorator)("CharacterJoinerService"),t.IThemeService=(0,s.createDecorator)("ThemeService"),t.ILinkProviderService=(0,s.createDecorator)("LinkProviderService")},9078:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.ThemeService=void 0;const n=i(7174),o=i(9302),a=i(4103),l=i(7150),h=i(6501),c=i(802),d=a.css.toColor("#ffffff"),u=a.css.toColor("#000000"),_=a.css.toColor("#ffffff"),f=u,p={css:"rgba(255, 255, 255, 0.3)",rgba:4294967117},g=d;let m=class extends l.Disposable{get colors(){return this._colors}constructor(e){super(),this._optionsService=e,this._contrastCache=new n.ColorContrastCache,this._halfContrastCache=new n.ColorContrastCache,this._onChangeColors=this._register(new c.Emitter),this.onChangeColors=this._onChangeColors.event,this._colors={foreground:d,background:u,cursor:_,cursorAccent:f,selectionForeground:void 0,selectionBackgroundTransparent:p,selectionBackgroundOpaque:a.color.blend(u,p),selectionInactiveBackgroundTransparent:p,selectionInactiveBackgroundOpaque:a.color.blend(u,p),scrollbarSliderBackground:a.color.opacity(d,.2),scrollbarSliderHoverBackground:a.color.opacity(d,.4),scrollbarSliderActiveBackground:a.color.opacity(d,.5),overviewRulerBorder:d,ansi:o.DEFAULT_ANSI_COLORS.slice(),contrastCache:this._contrastCache,halfContrastCache:this._halfContrastCache},this._updateRestoreColors(),this._setTheme(this._optionsService.rawOptions.theme),this._register(this._optionsService.onSpecificOptionChange("minimumContrastRatio",(()=>this._contrastCache.clear()))),this._register(this._optionsService.onSpecificOptionChange("theme",(()=>this._setTheme(this._optionsService.rawOptions.theme))))}_setTheme(e={}){const t=this._colors;if(t.foreground=v(e.foreground,d),t.background=v(e.background,u),t.cursor=a.color.blend(t.background,v(e.cursor,_)),t.cursorAccent=a.color.blend(t.background,v(e.cursorAccent,f)),t.selectionBackgroundTransparent=v(e.selectionBackground,p),t.selectionBackgroundOpaque=a.color.blend(t.background,t.selectionBackgroundTransparent),t.selectionInactiveBackgroundTransparent=v(e.selectionInactiveBackground,t.selectionBackgroundTransparent),t.selectionInactiveBackgroundOpaque=a.color.blend(t.background,t.selectionInactiveBackgroundTransparent),t.selectionForeground=e.selectionForeground?v(e.selectionForeground,a.NULL_COLOR):void 0,t.selectionForeground===a.NULL_COLOR&&(t.selectionForeground=void 0),a.color.isOpaque(t.selectionBackgroundTransparent)){const e=.3;t.selectionBackgroundTransparent=a.color.opacity(t.selectionBackgroundTransparent,e)}if(a.color.isOpaque(t.selectionInactiveBackgroundTransparent)){const e=.3;t.selectionInactiveBackgroundTransparent=a.color.opacity(t.selectionInactiveBackgroundTransparent,e)}if(t.scrollbarSliderBackground=v(e.scrollbarSliderBackground,a.color.opacity(t.foreground,.2)),t.scrollbarSliderHoverBackground=v(e.scrollbarSliderHoverBackground,a.color.opacity(t.foreground,.4)),t.scrollbarSliderActiveBackground=v(e.scrollbarSliderActiveBackground,a.color.opacity(t.foreground,.5)),t.overviewRulerBorder=v(e.overviewRulerBorder,g),t.ansi=o.DEFAULT_ANSI_COLORS.slice(),t.ansi[0]=v(e.black,o.DEFAULT_ANSI_COLORS[0]),t.ansi[1]=v(e.red,o.DEFAULT_ANSI_COLORS[1]),t.ansi[2]=v(e.green,o.DEFAULT_ANSI_COLORS[2]),t.ansi[3]=v(e.yellow,o.DEFAULT_ANSI_COLORS[3]),t.ansi[4]=v(e.blue,o.DEFAULT_ANSI_COLORS[4]),t.ansi[5]=v(e.magenta,o.DEFAULT_ANSI_COLORS[5]),t.ansi[6]=v(e.cyan,o.DEFAULT_ANSI_COLORS[6]),t.ansi[7]=v(e.white,o.DEFAULT_ANSI_COLORS[7]),t.ansi[8]=v(e.brightBlack,o.DEFAULT_ANSI_COLORS[8]),t.ansi[9]=v(e.brightRed,o.DEFAULT_ANSI_COLORS[9]),t.ansi[10]=v(e.brightGreen,o.DEFAULT_ANSI_COLORS[10]),t.ansi[11]=v(e.brightYellow,o.DEFAULT_ANSI_COLORS[11]),t.ansi[12]=v(e.brightBlue,o.DEFAULT_ANSI_COLORS[12]),t.ansi[13]=v(e.brightMagenta,o.DEFAULT_ANSI_COLORS[13]),t.ansi[14]=v(e.brightCyan,o.DEFAULT_ANSI_COLORS[14]),t.ansi[15]=v(e.brightWhite,o.DEFAULT_ANSI_COLORS[15]),e.extendedAnsi){const i=Math.min(t.ansi.length-16,e.extendedAnsi.length);for(let s=0;s{Object.defineProperty(t,"__esModule",{value:!0}),t.CircularList=void 0;const s=i(7150),r=i(802);class n extends s.Disposable{constructor(e){super(),this._maxLength=e,this.onDeleteEmitter=this._register(new r.Emitter),this.onDelete=this.onDeleteEmitter.event,this.onInsertEmitter=this._register(new r.Emitter),this.onInsert=this.onInsertEmitter.event,this.onTrimEmitter=this._register(new r.Emitter),this.onTrim=this.onTrimEmitter.event,this._array=new Array(this._maxLength),this._startIndex=0,this._length=0}get maxLength(){return this._maxLength}set maxLength(e){if(this._maxLength===e)return;const t=new Array(e);for(let i=0;ithis._length)for(let t=this._length;t=e;t--)this._array[this._getCyclicIndex(t+i.length)]=this._array[this._getCyclicIndex(t)];for(let t=0;tthis._maxLength){const e=this._length+i.length-this._maxLength;this._startIndex+=e,this._length=this._maxLength,this.onTrimEmitter.fire(e)}else this._length+=i.length}trimStart(e){e>this._length&&(e=this._length),this._startIndex+=e,this._length-=e,this.onTrimEmitter.fire(e)}shiftElements(e,t,i){if(!(t<=0)){if(e<0||e>=this._length)throw new Error("start argument out of range");if(e+i<0)throw new Error("Cannot shift elements in list beyond index 0");if(i>0){for(let s=t-1;s>=0;s--)this.set(e+s+i,this.get(e+s));const s=e+t+i-this._length;if(s>0)for(this._length+=s;this._length>this._maxLength;)this._length--,this._startIndex++,this.onTrimEmitter.fire(1)}else for(let s=0;s{Object.defineProperty(t,"__esModule",{value:!0}),t.clone=function e(t,i=5){if("object"!=typeof t)return t;const s=Array.isArray(t)?[]:{};for(const r in t)s[r]=i<=1?t[r]:t[r]&&e(t[r],i-1);return s}},4103:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.rgba=t.rgb=t.css=t.color=t.channels=t.NULL_COLOR=void 0,t.toPaddedHex=d,t.contrastRatio=u;let i=0,s=0,r=0,n=0;var o,a,l,h,c;function d(e){const t=e.toString(16);return t.length<2?"0"+t:t}function u(e,t){return e>>0},e.toColor=function(t,i,s,r){return{css:e.toCss(t,i,s,r),rgba:e.toRgba(t,i,s,r)}}}(o||(t.channels=o={})),function(e){function t(e,t){return n=Math.round(255*t),[i,s,r]=c.toChannels(e.rgba),{css:o.toCss(i,s,r,n),rgba:o.toRgba(i,s,r,n)}}e.blend=function(e,t){if(n=(255&t.rgba)/255,1===n)return{css:t.css,rgba:t.rgba};const a=t.rgba>>24&255,l=t.rgba>>16&255,h=t.rgba>>8&255,c=e.rgba>>24&255,d=e.rgba>>16&255,u=e.rgba>>8&255;return i=c+Math.round((a-c)*n),s=d+Math.round((l-d)*n),r=u+Math.round((h-u)*n),{css:o.toCss(i,s,r),rgba:o.toRgba(i,s,r)}},e.isOpaque=function(e){return!(255&~e.rgba)},e.ensureContrastRatio=function(e,t,i){const s=c.ensureContrastRatio(e.rgba,t.rgba,i);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},e.opaque=function(e){const t=(255|e.rgba)>>>0;return[i,s,r]=c.toChannels(t),{css:o.toCss(i,s,r),rgba:t}},e.opacity=t,e.multiplyOpacity=function(e,i){return n=255&e.rgba,t(e,n*i/255)},e.toColorRGB=function(e){return[e.rgba>>24&255,e.rgba>>16&255,e.rgba>>8&255]}}(a||(t.color=a={})),function(e){let t,a;try{const e=document.createElement("canvas");e.width=1,e.height=1;const i=e.getContext("2d",{willReadFrequently:!0});i&&(t=i,t.globalCompositeOperation="copy",a=t.createLinearGradient(0,0,1,1))}catch{}e.toColor=function(e){if(e.match(/#[\da-f]{3,8}/i))switch(e.length){case 4:return i=parseInt(e.slice(1,2).repeat(2),16),s=parseInt(e.slice(2,3).repeat(2),16),r=parseInt(e.slice(3,4).repeat(2),16),o.toColor(i,s,r);case 5:return i=parseInt(e.slice(1,2).repeat(2),16),s=parseInt(e.slice(2,3).repeat(2),16),r=parseInt(e.slice(3,4).repeat(2),16),n=parseInt(e.slice(4,5).repeat(2),16),o.toColor(i,s,r,n);case 7:return{css:e,rgba:(parseInt(e.slice(1),16)<<8|255)>>>0};case 9:return{css:e,rgba:parseInt(e.slice(1),16)>>>0}}const l=e.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(l)return i=parseInt(l[1]),s=parseInt(l[2]),r=parseInt(l[3]),n=Math.round(255*(void 0===l[5]?1:parseFloat(l[5]))),o.toColor(i,s,r,n);if(!t||!a)throw new Error("css.toColor: Unsupported css format");if(t.fillStyle=a,t.fillStyle=e,"string"!=typeof t.fillStyle)throw new Error("css.toColor: Unsupported css format");if(t.fillRect(0,0,1,1),[i,s,r,n]=t.getImageData(0,0,1,1).data,255!==n)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(i,s,r,n),css:e}}}(l||(t.css=l={})),function(e){function t(e,t,i){const s=e/255,r=t/255,n=i/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(r<=.03928?r/12.92:Math.pow((r+.055)/1.055,2.4))+.0722*(n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4))}e.relativeLuminance=function(e){return t(e>>16&255,e>>8&255,255&e)},e.relativeLuminance2=t}(h||(t.rgb=h={})),function(e){function t(e,t,i){const s=e>>24&255,r=e>>16&255,n=e>>8&255;let o=t>>24&255,a=t>>16&255,l=t>>8&255,c=u(h.relativeLuminance2(o,a,l),h.relativeLuminance2(s,r,n));for(;c0||a>0||l>0);)o-=Math.max(0,Math.ceil(.1*o)),a-=Math.max(0,Math.ceil(.1*a)),l-=Math.max(0,Math.ceil(.1*l)),c=u(h.relativeLuminance2(o,a,l),h.relativeLuminance2(s,r,n));return(o<<24|a<<16|l<<8|255)>>>0}function a(e,t,i){const s=e>>24&255,r=e>>16&255,n=e>>8&255;let o=t>>24&255,a=t>>16&255,l=t>>8&255,c=u(h.relativeLuminance2(o,a,l),h.relativeLuminance2(s,r,n));for(;c>>0}e.blend=function(e,t){if(n=(255&t)/255,1===n)return t;const a=t>>24&255,l=t>>16&255,h=t>>8&255,c=e>>24&255,d=e>>16&255,u=e>>8&255;return i=c+Math.round((a-c)*n),s=d+Math.round((l-d)*n),r=u+Math.round((h-u)*n),o.toRgba(i,s,r)},e.ensureContrastRatio=function(e,i,s){const r=h.relativeLuminance(e>>8),n=h.relativeLuminance(i>>8);if(u(r,n)>8));if(ou(r,h.relativeLuminance(t>>8))?n:t}return n}const o=a(e,i,s),l=u(r,h.relativeLuminance(o>>8));if(lu(r,h.relativeLuminance(n>>8))?o:n}return o}},e.reduceLuminance=t,e.increaseLuminance=a,e.toChannels=function(e){return[e>>24&255,e>>16&255,e>>8&255,255&e]}}(c||(t.rgba=c={}))},5777:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreTerminal=void 0;const s=i(6501),r=i(6025),n=i(7276),o=i(9640),a=i(56),l=i(4071),h=i(7792),c=i(6415),d=i(5746),u=i(5882),_=i(2486),f=i(3562),p=i(8811),g=i(802),m=i(7150);let v=!1;class S extends m.Disposable{get onScroll(){return this._onScrollApi||(this._onScrollApi=this._register(new g.Emitter),this._onScroll.event((e=>{this._onScrollApi?.fire(e.position)}))),this._onScrollApi.event}get cols(){return this._bufferService.cols}get rows(){return this._bufferService.rows}get buffers(){return this._bufferService.buffers}get options(){return this.optionsService.options}set options(e){for(const t in e)this.optionsService.options[t]=e[t]}constructor(e){super(),this._windowsWrappingHeuristics=this._register(new m.MutableDisposable),this._onBinary=this._register(new g.Emitter),this.onBinary=this._onBinary.event,this._onData=this._register(new g.Emitter),this.onData=this._onData.event,this._onLineFeed=this._register(new g.Emitter),this.onLineFeed=this._onLineFeed.event,this._onResize=this._register(new g.Emitter),this.onResize=this._onResize.event,this._onWriteParsed=this._register(new g.Emitter),this.onWriteParsed=this._onWriteParsed.event,this._onScroll=this._register(new g.Emitter),this._instantiationService=new r.InstantiationService,this.optionsService=this._register(new a.OptionsService(e)),this._instantiationService.setService(s.IOptionsService,this.optionsService),this._bufferService=this._register(this._instantiationService.createInstance(o.BufferService)),this._instantiationService.setService(s.IBufferService,this._bufferService),this._logService=this._register(this._instantiationService.createInstance(n.LogService)),this._instantiationService.setService(s.ILogService,this._logService),this.coreService=this._register(this._instantiationService.createInstance(l.CoreService)),this._instantiationService.setService(s.ICoreService,this.coreService),this.coreMouseService=this._register(this._instantiationService.createInstance(h.CoreMouseService)),this._instantiationService.setService(s.ICoreMouseService,this.coreMouseService),this.unicodeService=this._register(this._instantiationService.createInstance(c.UnicodeService)),this._instantiationService.setService(s.IUnicodeService,this.unicodeService),this._charsetService=this._instantiationService.createInstance(d.CharsetService),this._instantiationService.setService(s.ICharsetService,this._charsetService),this._oscLinkService=this._instantiationService.createInstance(p.OscLinkService),this._instantiationService.setService(s.IOscLinkService,this._oscLinkService),this._inputHandler=this._register(new _.InputHandler(this._bufferService,this._charsetService,this.coreService,this._logService,this.optionsService,this._oscLinkService,this.coreMouseService,this.unicodeService)),this._register(g.Event.forward(this._inputHandler.onLineFeed,this._onLineFeed)),this._register(this._inputHandler),this._register(g.Event.forward(this._bufferService.onResize,this._onResize)),this._register(g.Event.forward(this.coreService.onData,this._onData)),this._register(g.Event.forward(this.coreService.onBinary,this._onBinary)),this._register(this.coreService.onRequestScrollToBottom((()=>this.scrollToBottom(!0)))),this._register(this.coreService.onUserInput((()=>this._writeBuffer.handleUserInput()))),this._register(this.optionsService.onMultipleOptionChange(["windowsMode","windowsPty"],(()=>this._handleWindowsPtyOptionChange()))),this._register(this._bufferService.onScroll((()=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)}))),this._writeBuffer=this._register(new f.WriteBuffer(((e,t)=>this._inputHandler.parse(e,t)))),this._register(g.Event.forward(this._writeBuffer.onWriteParsed,this._onWriteParsed))}write(e,t){this._writeBuffer.write(e,t)}writeSync(e,t){this._logService.logLevel<=s.LogLevelEnum.WARN&&!v&&(this._logService.warn("writeSync is unreliable and will be removed soon."),v=!0),this._writeBuffer.writeSync(e,t)}input(e,t=!0){this.coreService.triggerDataEvent(e,t)}resize(e,t){isNaN(e)||isNaN(t)||(e=Math.max(e,o.MINIMUM_COLS),t=Math.max(t,o.MINIMUM_ROWS),this._bufferService.resize(e,t))}scroll(e,t=!1){this._bufferService.scroll(e,t)}scrollLines(e,t){this._bufferService.scrollLines(e,t)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){const t=e-this._bufferService.buffer.ydisp;0!==t&&this.scrollLines(t)}registerEscHandler(e,t){return this._inputHandler.registerEscHandler(e,t)}registerDcsHandler(e,t){return this._inputHandler.registerDcsHandler(e,t)}registerCsiHandler(e,t){return this._inputHandler.registerCsiHandler(e,t)}registerOscHandler(e,t){return this._inputHandler.registerOscHandler(e,t)}_setup(){this._handleWindowsPtyOptionChange()}reset(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.coreMouseService.reset()}_handleWindowsPtyOptionChange(){let e=!1;const t=this.optionsService.rawOptions.windowsPty;t&&void 0!==t.buildNumber&&void 0!==t.buildNumber?e=!!("conpty"===t.backend&&t.buildNumber<21376):this.optionsService.rawOptions.windowsMode&&(e=!0),e?this._enableWindowsWrappingHeuristics():this._windowsWrappingHeuristics.clear()}_enableWindowsWrappingHeuristics(){if(!this._windowsWrappingHeuristics.value){const e=[];e.push(this.onLineFeed(u.updateWindowsModeWrappedState.bind(null,this._bufferService))),e.push(this.registerCsiHandler({final:"H"},(()=>((0,u.updateWindowsModeWrappedState)(this._bufferService),!1)))),this._windowsWrappingHeuristics.value=(0,m.toDisposable)((()=>{for(const t of e)t.dispose()}))}}}t.CoreTerminal=S},2486:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.InputHandler=t.WindowsOptionsReportType=void 0,t.isValidColorIndex=R;const n=i(3534),o=i(6760),a=i(6717),l=i(7150),h=i(726),c=i(6107),d=i(8938),u=i(3055),_=i(5451),f=i(6501),p=i(6415),g=i(1346),m=i(9823),v=i(8693),S=i(802),b={"(":0,")":1,"*":2,"+":3,"-":1,".":2},C=131072;function y(e,t){if(e>24)return t.setWinLines||!1;switch(e){case 1:return!!t.restoreWin;case 2:return!!t.minimizeWin;case 3:return!!t.setWinPosition;case 4:return!!t.setWinSizePixels;case 5:return!!t.raiseWin;case 6:return!!t.lowerWin;case 7:return!!t.refreshWin;case 8:return!!t.setWinSizeChars;case 9:return!!t.maximizeWin;case 10:return!!t.fullscreenWin;case 11:return!!t.getWinState;case 13:return!!t.getWinPosition;case 14:return!!t.getWinSizePixels;case 15:return!!t.getScreenSizePixels;case 16:return!!t.getCellSizePixels;case 18:return!!t.getWinSizeChars;case 19:return!!t.getScreenSizeChars;case 20:return!!t.getIconTitle;case 21:return!!t.getWinTitle;case 22:return!!t.pushTitle;case 23:return!!t.popTitle;case 24:return!!t.setWinLines}return!1}var w;!function(e){e[e.GET_WIN_SIZE_PIXELS=0]="GET_WIN_SIZE_PIXELS",e[e.GET_CELL_SIZE_PIXELS=1]="GET_CELL_SIZE_PIXELS"}(w||(t.WindowsOptionsReportType=w={}));let E=0;class D extends l.Disposable{getAttrData(){return this._curAttrData}constructor(e,t,i,s,r,l,d,u,_=new a.EscapeSequenceParser){super(),this._bufferService=e,this._charsetService=t,this._coreService=i,this._logService=s,this._optionsService=r,this._oscLinkService=l,this._coreMouseService=d,this._unicodeService=u,this._parser=_,this._parseBuffer=new Uint32Array(4096),this._stringDecoder=new h.StringToUtf32,this._utf8Decoder=new h.Utf8ToUtf32,this._windowTitle="",this._iconName="",this._windowTitleStack=[],this._iconNameStack=[],this._curAttrData=c.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=c.DEFAULT_ATTR_DATA.clone(),this._onRequestBell=this._register(new S.Emitter),this.onRequestBell=this._onRequestBell.event,this._onRequestRefreshRows=this._register(new S.Emitter),this.onRequestRefreshRows=this._onRequestRefreshRows.event,this._onRequestReset=this._register(new S.Emitter),this.onRequestReset=this._onRequestReset.event,this._onRequestSendFocus=this._register(new S.Emitter),this.onRequestSendFocus=this._onRequestSendFocus.event,this._onRequestSyncScrollBar=this._register(new S.Emitter),this.onRequestSyncScrollBar=this._onRequestSyncScrollBar.event,this._onRequestWindowsOptionsReport=this._register(new S.Emitter),this.onRequestWindowsOptionsReport=this._onRequestWindowsOptionsReport.event,this._onA11yChar=this._register(new S.Emitter),this.onA11yChar=this._onA11yChar.event,this._onA11yTab=this._register(new S.Emitter),this.onA11yTab=this._onA11yTab.event,this._onCursorMove=this._register(new S.Emitter),this.onCursorMove=this._onCursorMove.event,this._onLineFeed=this._register(new S.Emitter),this.onLineFeed=this._onLineFeed.event,this._onScroll=this._register(new S.Emitter),this.onScroll=this._onScroll.event,this._onTitleChange=this._register(new S.Emitter),this.onTitleChange=this._onTitleChange.event,this._onColor=this._register(new S.Emitter),this.onColor=this._onColor.event,this._parseStack={paused:!1,cursorStartX:0,cursorStartY:0,decodedLength:0,position:0},this._specialColors=[256,257,258],this._register(this._parser),this._dirtyRowTracker=new L(this._bufferService),this._activeBuffer=this._bufferService.buffer,this._register(this._bufferService.buffers.onBufferActivate((e=>this._activeBuffer=e.activeBuffer))),this._parser.setCsiHandlerFallback(((e,t)=>{this._logService.debug("Unknown CSI code: ",{identifier:this._parser.identToString(e),params:t.toArray()})})),this._parser.setEscHandlerFallback((e=>{this._logService.debug("Unknown ESC code: ",{identifier:this._parser.identToString(e)})})),this._parser.setExecuteHandlerFallback((e=>{this._logService.debug("Unknown EXECUTE code: ",{code:e})})),this._parser.setOscHandlerFallback(((e,t,i)=>{this._logService.debug("Unknown OSC code: ",{identifier:e,action:t,data:i})})),this._parser.setDcsHandlerFallback(((e,t,i)=>{"HOOK"===t&&(i=i.toArray()),this._logService.debug("Unknown DCS code: ",{identifier:this._parser.identToString(e),action:t,payload:i})})),this._parser.setPrintHandler(((e,t,i)=>this.print(e,t,i))),this._parser.registerCsiHandler({final:"@"},(e=>this.insertChars(e))),this._parser.registerCsiHandler({intermediates:" ",final:"@"},(e=>this.scrollLeft(e))),this._parser.registerCsiHandler({final:"A"},(e=>this.cursorUp(e))),this._parser.registerCsiHandler({intermediates:" ",final:"A"},(e=>this.scrollRight(e))),this._parser.registerCsiHandler({final:"B"},(e=>this.cursorDown(e))),this._parser.registerCsiHandler({final:"C"},(e=>this.cursorForward(e))),this._parser.registerCsiHandler({final:"D"},(e=>this.cursorBackward(e))),this._parser.registerCsiHandler({final:"E"},(e=>this.cursorNextLine(e))),this._parser.registerCsiHandler({final:"F"},(e=>this.cursorPrecedingLine(e))),this._parser.registerCsiHandler({final:"G"},(e=>this.cursorCharAbsolute(e))),this._parser.registerCsiHandler({final:"H"},(e=>this.cursorPosition(e))),this._parser.registerCsiHandler({final:"I"},(e=>this.cursorForwardTab(e))),this._parser.registerCsiHandler({final:"J"},(e=>this.eraseInDisplay(e,!1))),this._parser.registerCsiHandler({prefix:"?",final:"J"},(e=>this.eraseInDisplay(e,!0))),this._parser.registerCsiHandler({final:"K"},(e=>this.eraseInLine(e,!1))),this._parser.registerCsiHandler({prefix:"?",final:"K"},(e=>this.eraseInLine(e,!0))),this._parser.registerCsiHandler({final:"L"},(e=>this.insertLines(e))),this._parser.registerCsiHandler({final:"M"},(e=>this.deleteLines(e))),this._parser.registerCsiHandler({final:"P"},(e=>this.deleteChars(e))),this._parser.registerCsiHandler({final:"S"},(e=>this.scrollUp(e))),this._parser.registerCsiHandler({final:"T"},(e=>this.scrollDown(e))),this._parser.registerCsiHandler({final:"X"},(e=>this.eraseChars(e))),this._parser.registerCsiHandler({final:"Z"},(e=>this.cursorBackwardTab(e))),this._parser.registerCsiHandler({final:"`"},(e=>this.charPosAbsolute(e))),this._parser.registerCsiHandler({final:"a"},(e=>this.hPositionRelative(e))),this._parser.registerCsiHandler({final:"b"},(e=>this.repeatPrecedingCharacter(e))),this._parser.registerCsiHandler({final:"c"},(e=>this.sendDeviceAttributesPrimary(e))),this._parser.registerCsiHandler({prefix:">",final:"c"},(e=>this.sendDeviceAttributesSecondary(e))),this._parser.registerCsiHandler({final:"d"},(e=>this.linePosAbsolute(e))),this._parser.registerCsiHandler({final:"e"},(e=>this.vPositionRelative(e))),this._parser.registerCsiHandler({final:"f"},(e=>this.hVPosition(e))),this._parser.registerCsiHandler({final:"g"},(e=>this.tabClear(e))),this._parser.registerCsiHandler({final:"h"},(e=>this.setMode(e))),this._parser.registerCsiHandler({prefix:"?",final:"h"},(e=>this.setModePrivate(e))),this._parser.registerCsiHandler({final:"l"},(e=>this.resetMode(e))),this._parser.registerCsiHandler({prefix:"?",final:"l"},(e=>this.resetModePrivate(e))),this._parser.registerCsiHandler({final:"m"},(e=>this.charAttributes(e))),this._parser.registerCsiHandler({final:"n"},(e=>this.deviceStatus(e))),this._parser.registerCsiHandler({prefix:"?",final:"n"},(e=>this.deviceStatusPrivate(e))),this._parser.registerCsiHandler({intermediates:"!",final:"p"},(e=>this.softReset(e))),this._parser.registerCsiHandler({intermediates:" ",final:"q"},(e=>this.setCursorStyle(e))),this._parser.registerCsiHandler({final:"r"},(e=>this.setScrollRegion(e))),this._parser.registerCsiHandler({final:"s"},(e=>this.saveCursor(e))),this._parser.registerCsiHandler({final:"t"},(e=>this.windowOptions(e))),this._parser.registerCsiHandler({final:"u"},(e=>this.restoreCursor(e))),this._parser.registerCsiHandler({intermediates:"'",final:"}"},(e=>this.insertColumns(e))),this._parser.registerCsiHandler({intermediates:"'",final:"~"},(e=>this.deleteColumns(e))),this._parser.registerCsiHandler({intermediates:'"',final:"q"},(e=>this.selectProtected(e))),this._parser.registerCsiHandler({intermediates:"$",final:"p"},(e=>this.requestMode(e,!0))),this._parser.registerCsiHandler({prefix:"?",intermediates:"$",final:"p"},(e=>this.requestMode(e,!1))),this._parser.setExecuteHandler(n.C0.BEL,(()=>this.bell())),this._parser.setExecuteHandler(n.C0.LF,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.VT,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.FF,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.CR,(()=>this.carriageReturn())),this._parser.setExecuteHandler(n.C0.BS,(()=>this.backspace())),this._parser.setExecuteHandler(n.C0.HT,(()=>this.tab())),this._parser.setExecuteHandler(n.C0.SO,(()=>this.shiftOut())),this._parser.setExecuteHandler(n.C0.SI,(()=>this.shiftIn())),this._parser.setExecuteHandler(n.C1.IND,(()=>this.index())),this._parser.setExecuteHandler(n.C1.NEL,(()=>this.nextLine())),this._parser.setExecuteHandler(n.C1.HTS,(()=>this.tabSet())),this._parser.registerOscHandler(0,new g.OscHandler((e=>(this.setTitle(e),this.setIconName(e),!0)))),this._parser.registerOscHandler(1,new g.OscHandler((e=>this.setIconName(e)))),this._parser.registerOscHandler(2,new g.OscHandler((e=>this.setTitle(e)))),this._parser.registerOscHandler(4,new g.OscHandler((e=>this.setOrReportIndexedColor(e)))),this._parser.registerOscHandler(8,new g.OscHandler((e=>this.setHyperlink(e)))),this._parser.registerOscHandler(10,new g.OscHandler((e=>this.setOrReportFgColor(e)))),this._parser.registerOscHandler(11,new g.OscHandler((e=>this.setOrReportBgColor(e)))),this._parser.registerOscHandler(12,new g.OscHandler((e=>this.setOrReportCursorColor(e)))),this._parser.registerOscHandler(104,new g.OscHandler((e=>this.restoreIndexedColor(e)))),this._parser.registerOscHandler(110,new g.OscHandler((e=>this.restoreFgColor(e)))),this._parser.registerOscHandler(111,new g.OscHandler((e=>this.restoreBgColor(e)))),this._parser.registerOscHandler(112,new g.OscHandler((e=>this.restoreCursorColor(e)))),this._parser.registerEscHandler({final:"7"},(()=>this.saveCursor())),this._parser.registerEscHandler({final:"8"},(()=>this.restoreCursor())),this._parser.registerEscHandler({final:"D"},(()=>this.index())),this._parser.registerEscHandler({final:"E"},(()=>this.nextLine())),this._parser.registerEscHandler({final:"H"},(()=>this.tabSet())),this._parser.registerEscHandler({final:"M"},(()=>this.reverseIndex())),this._parser.registerEscHandler({final:"="},(()=>this.keypadApplicationMode())),this._parser.registerEscHandler({final:">"},(()=>this.keypadNumericMode())),this._parser.registerEscHandler({final:"c"},(()=>this.fullReset())),this._parser.registerEscHandler({final:"n"},(()=>this.setgLevel(2))),this._parser.registerEscHandler({final:"o"},(()=>this.setgLevel(3))),this._parser.registerEscHandler({final:"|"},(()=>this.setgLevel(3))),this._parser.registerEscHandler({final:"}"},(()=>this.setgLevel(2))),this._parser.registerEscHandler({final:"~"},(()=>this.setgLevel(1))),this._parser.registerEscHandler({intermediates:"%",final:"@"},(()=>this.selectDefaultCharset())),this._parser.registerEscHandler({intermediates:"%",final:"G"},(()=>this.selectDefaultCharset()));for(const e in o.CHARSETS)this._parser.registerEscHandler({intermediates:"(",final:e},(()=>this.selectCharset("("+e))),this._parser.registerEscHandler({intermediates:")",final:e},(()=>this.selectCharset(")"+e))),this._parser.registerEscHandler({intermediates:"*",final:e},(()=>this.selectCharset("*"+e))),this._parser.registerEscHandler({intermediates:"+",final:e},(()=>this.selectCharset("+"+e))),this._parser.registerEscHandler({intermediates:"-",final:e},(()=>this.selectCharset("-"+e))),this._parser.registerEscHandler({intermediates:".",final:e},(()=>this.selectCharset("."+e))),this._parser.registerEscHandler({intermediates:"/",final:e},(()=>this.selectCharset("/"+e)));this._parser.registerEscHandler({intermediates:"#",final:"8"},(()=>this.screenAlignmentPattern())),this._parser.setErrorHandler((e=>(this._logService.error("Parsing error: ",e),e))),this._parser.registerDcsHandler({intermediates:"$",final:"q"},new m.DcsHandler(((e,t)=>this.requestStatusString(e,t))))}_preserveStack(e,t,i,s){this._parseStack.paused=!0,this._parseStack.cursorStartX=e,this._parseStack.cursorStartY=t,this._parseStack.decodedLength=i,this._parseStack.position=s}_logSlowResolvingAsync(e){this._logService.logLevel<=f.LogLevelEnum.WARN&&Promise.race([e,new Promise(((e,t)=>setTimeout((()=>t("#SLOW_TIMEOUT")),5e3)))]).catch((e=>{if("#SLOW_TIMEOUT"!==e)throw e;console.warn("async parser handler taking longer than 5000 ms")}))}_getCurrentLinkId(){return this._curAttrData.extended.urlId}parse(e,t){let i,s=this._activeBuffer.x,r=this._activeBuffer.y,n=0;const o=this._parseStack.paused;if(o){if(i=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,t))return this._logSlowResolvingAsync(i),i;s=this._parseStack.cursorStartX,r=this._parseStack.cursorStartY,this._parseStack.paused=!1,e.length>C&&(n=this._parseStack.position+C)}if(this._logService.logLevel<=f.LogLevelEnum.DEBUG&&this._logService.debug("parsing data "+("string"==typeof e?` "${e}"`:` "${Array.prototype.map.call(e,(e=>String.fromCharCode(e))).join("")}"`)),this._logService.logLevel===f.LogLevelEnum.TRACE&&this._logService.trace("parsing data (codes)","string"==typeof e?e.split("").map((e=>e.charCodeAt(0))):e),this._parseBuffer.lengthC)for(let t=n;t0&&2===f.getWidth(this._activeBuffer.x-1)&&f.setCellFromCodepoint(this._activeBuffer.x-1,0,1,_);let g=this._parser.precedingJoinState;for(let m=t;ma)if(l){const e=f;let t=this._activeBuffer.x-v;for(this._activeBuffer.x=v,this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData(),!0)):(this._activeBuffer.y>=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),f=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y),v>0&&f instanceof c.BufferLine&&f.copyCellsFrom(e,t,0,v,!1);t=0;)f.setCellFromCodepoint(this._activeBuffer.x++,0,0,_)}else if(u&&(f.insertCells(this._activeBuffer.x,r-v,this._activeBuffer.getNullCell(_)),2===f.getWidth(a-1)&&f.setCellFromCodepoint(a-1,d.NULL_CELL_CODE,d.NULL_CELL_WIDTH,_)),f.setCellFromCodepoint(this._activeBuffer.x++,s,r,_),r>0)for(;--r;)f.setCellFromCodepoint(this._activeBuffer.x++,0,0,_)}this._parser.precedingJoinState=g,this._activeBuffer.x0&&0===f.getWidth(this._activeBuffer.x)&&!f.hasContent(this._activeBuffer.x)&&f.setCellFromCodepoint(this._activeBuffer.x,0,1,_),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}registerCsiHandler(e,t){return"t"!==e.final||e.prefix||e.intermediates?this._parser.registerCsiHandler(e,t):this._parser.registerCsiHandler(e,(e=>!y(e.params[0],this._optionsService.rawOptions.windowOptions)||t(e)))}registerDcsHandler(e,t){return this._parser.registerDcsHandler(e,new m.DcsHandler(t))}registerEscHandler(e,t){return this._parser.registerEscHandler(e,t)}registerOscHandler(e,t){return this._parser.registerOscHandler(e,new g.OscHandler(t))}bell(){return this._onRequestBell.fire(),!0}lineFeed(){return this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows?this._activeBuffer.y=this._bufferService.rows-1:this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0}carriageReturn(){return this._activeBuffer.x=0,!0}backspace(){if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(0===this._activeBuffer.x&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)?.isWrapped){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;const e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);e.hasWidth(this._activeBuffer.x)&&!e.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0}tab(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;const e=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-e),!0}shiftOut(){return this._charsetService.setgLevel(1),!0}shiftIn(){return this._charsetService.setgLevel(0),!0}_restrictCursor(e=this._bufferService.cols-1){this._activeBuffer.x=Math.min(e,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_setCursor(e,t){this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=e,this._activeBuffer.y=this._activeBuffer.scrollTop+t):(this._activeBuffer.x=e,this._activeBuffer.y=t),this._restrictCursor(),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_moveCursor(e,t){this._restrictCursor(),this._setCursor(this._activeBuffer.x+e,this._activeBuffer.y+t)}cursorUp(e){const t=this._activeBuffer.y-this._activeBuffer.scrollTop;return t>=0?this._moveCursor(0,-Math.min(t,e.params[0]||1)):this._moveCursor(0,-(e.params[0]||1)),!0}cursorDown(e){const t=this._activeBuffer.scrollBottom-this._activeBuffer.y;return t>=0?this._moveCursor(0,Math.min(t,e.params[0]||1)):this._moveCursor(0,e.params[0]||1),!0}cursorForward(e){return this._moveCursor(e.params[0]||1,0),!0}cursorBackward(e){return this._moveCursor(-(e.params[0]||1),0),!0}cursorNextLine(e){return this.cursorDown(e),this._activeBuffer.x=0,!0}cursorPrecedingLine(e){return this.cursorUp(e),this._activeBuffer.x=0,!0}cursorCharAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}cursorPosition(e){return this._setCursor(e.length>=2?(e.params[1]||1)-1:0,(e.params[0]||1)-1),!0}charPosAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}hPositionRelative(e){return this._moveCursor(e.params[0]||1,0),!0}linePosAbsolute(e){return this._setCursor(this._activeBuffer.x,(e.params[0]||1)-1),!0}vPositionRelative(e){return this._moveCursor(0,e.params[0]||1),!0}hVPosition(e){return this.cursorPosition(e),!0}tabClear(e){const t=e.params[0];return 0===t?delete this._activeBuffer.tabs[this._activeBuffer.x]:3===t&&(this._activeBuffer.tabs={}),!0}cursorForwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0}cursorBackwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0}selectProtected(e){const t=e.params[0];return 1===t&&(this._curAttrData.bg|=536870912),2!==t&&0!==t||(this._curAttrData.bg&=-536870913),!0}_eraseInBufferLine(e,t,i,s=!1,r=!1){const n=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);n.replaceCells(t,i,this._activeBuffer.getNullCell(this._eraseAttrData()),r),s&&(n.isWrapped=!1)}_resetBufferLine(e,t=!1){const i=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);i&&(i.fill(this._activeBuffer.getNullCell(this._eraseAttrData()),t),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+e),i.isWrapped=!1)}eraseInDisplay(e,t=!1){let i;switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:for(i=this._activeBuffer.y,this._dirtyRowTracker.markDirty(i),this._eraseInBufferLine(i++,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);i=this._bufferService.cols&&(this._activeBuffer.lines.get(i+1).isWrapped=!1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0);break;case 2:if(this._optionsService.rawOptions.scrollOnEraseInDisplay){for(i=this._bufferService.rows,this._dirtyRowTracker.markRangeDirty(0,i-1);i--;){const e=this._activeBuffer.lines.get(this._activeBuffer.ybase+i);if(e?.getTrimmedLength())break}for(;i>=0;i--)this._bufferService.scroll(this._eraseAttrData())}else{for(i=this._bufferService.rows,this._dirtyRowTracker.markDirty(i-1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0)}break;case 3:const e=this._activeBuffer.lines.length-this._bufferService.rows;e>0&&(this._activeBuffer.lines.trimStart(e),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-e,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-e,0),this._onScroll.fire(0))}return!0}eraseInLine(e,t=!1){switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1,t);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0,t)}return this._dirtyRowTracker.markDirty(this._activeBuffer.y),!0}insertLines(e){this._restrictCursor();let t=e.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.y65535?2:1}let l=a;for(let e=1;e0||(this._is("xterm")||this._is("rxvt-unicode")||this._is("screen")?this._coreService.triggerDataEvent(n.C0.ESC+"[?1;2c"):this._is("linux")&&this._coreService.triggerDataEvent(n.C0.ESC+"[?6c")),!0}sendDeviceAttributesSecondary(e){return e.params[0]>0||(this._is("xterm")?this._coreService.triggerDataEvent(n.C0.ESC+"[>0;276;0c"):this._is("rxvt-unicode")?this._coreService.triggerDataEvent(n.C0.ESC+"[>85;95;0c"):this._is("linux")?this._coreService.triggerDataEvent(e.params[0]+"c"):this._is("screen")&&this._coreService.triggerDataEvent(n.C0.ESC+"[>83;40003;0c")),!0}_is(e){return 0===(this._optionsService.rawOptions.termName+"").indexOf(e)}setMode(e){for(let t=0;te?1:2,_=e.params[0];return f=_,p=t?2===_?4:4===_?u(o.modes.insertMode):12===_?3:20===_?u(d.convertEol):0:1===_?u(i.applicationCursorKeys):3===_?d.windowOptions.setWinLines?80===l?2:132===l?1:0:0:6===_?u(i.origin):7===_?u(i.wraparound):8===_?3:9===_?u("X10"===s):12===_?u(d.cursorBlink):25===_?u(!o.isCursorHidden):45===_?u(i.reverseWraparound):66===_?u(i.applicationKeypad):67===_?4:1e3===_?u("VT200"===s):1002===_?u("DRAG"===s):1003===_?u("ANY"===s):1004===_?u(i.sendFocus):1005===_?4:1006===_?u("SGR"===r):1015===_?4:1016===_?u("SGR_PIXELS"===r):1048===_?1:47===_||1047===_||1049===_?u(h===c):2004===_?u(i.bracketedPasteMode):2026===_?u(i.synchronizedOutput):0,o.triggerDataEvent(`${n.C0.ESC}[${t?"":"?"}${f};${p}$y`),!0;var f,p}_updateAttrColor(e,t,i,s,r){return 2===t?(e|=50331648,e&=-16777216,e|=_.AttributeData.fromColorRGB([i,s,r])):5===t&&(e&=-50331904,e|=33554432|255&i),e}_extractColor(e,t,i){const s=[0,0,-1,0,0,0];let r=0,n=0;do{if(s[n+r]=e.params[t+n],e.hasSubParams(t+n)){const i=e.getSubParams(t+n);let o=0;do{5===s[1]&&(r=1),s[n+o+1+r]=i[o]}while(++o=2||2===s[1]&&n+r>=5)break;s[1]&&(r=1)}while(++n+t5)&&(e=1),t.extended.underlineStyle=e,t.fg|=268435456,0===e&&(t.fg&=-268435457),t.updateExtended()}_processSGR0(e){e.fg=c.DEFAULT_ATTR_DATA.fg,e.bg=c.DEFAULT_ATTR_DATA.bg,e.extended=e.extended.clone(),e.extended.underlineStyle=0,e.extended.underlineColor&=-67108864,e.updateExtended()}charAttributes(e){if(1===e.length&&0===e.params[0])return this._processSGR0(this._curAttrData),!0;const t=e.length;let i;const s=this._curAttrData;for(let r=0;r=30&&i<=37?(s.fg&=-50331904,s.fg|=16777216|i-30):i>=40&&i<=47?(s.bg&=-50331904,s.bg|=16777216|i-40):i>=90&&i<=97?(s.fg&=-50331904,s.fg|=16777224|i-90):i>=100&&i<=107?(s.bg&=-50331904,s.bg|=16777224|i-100):0===i?this._processSGR0(s):1===i?s.fg|=134217728:3===i?s.bg|=67108864:4===i?(s.fg|=268435456,this._processUnderline(e.hasSubParams(r)?e.getSubParams(r)[0]:1,s)):5===i?s.fg|=536870912:7===i?s.fg|=67108864:8===i?s.fg|=1073741824:9===i?s.fg|=2147483648:2===i?s.bg|=134217728:21===i?this._processUnderline(2,s):22===i?(s.fg&=-134217729,s.bg&=-134217729):23===i?s.bg&=-67108865:24===i?(s.fg&=-268435457,this._processUnderline(0,s)):25===i?s.fg&=-536870913:27===i?s.fg&=-67108865:28===i?s.fg&=-1073741825:29===i?s.fg&=2147483647:39===i?(s.fg&=-67108864,s.fg|=16777215&c.DEFAULT_ATTR_DATA.fg):49===i?(s.bg&=-67108864,s.bg|=16777215&c.DEFAULT_ATTR_DATA.bg):38===i||48===i||58===i?r+=this._extractColor(e,r,s):53===i?s.bg|=1073741824:55===i?s.bg&=-1073741825:59===i?(s.extended=s.extended.clone(),s.extended.underlineColor=-1,s.updateExtended()):100===i?(s.fg&=-67108864,s.fg|=16777215&c.DEFAULT_ATTR_DATA.fg,s.bg&=-67108864,s.bg|=16777215&c.DEFAULT_ATTR_DATA.bg):this._logService.debug("Unknown SGR attribute: %d.",i);return!0}deviceStatus(e){switch(e.params[0]){case 5:this._coreService.triggerDataEvent(`${n.C0.ESC}[0n`);break;case 6:const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${n.C0.ESC}[${e};${t}R`)}return!0}deviceStatusPrivate(e){if(6===e.params[0]){const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${n.C0.ESC}[?${e};${t}R`)}return!0}softReset(e){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=c.DEFAULT_ATTR_DATA.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0}setCursorStyle(e){const t=0===e.length?1:e.params[0];if(0===t)this._coreService.decPrivateModes.cursorStyle=void 0,this._coreService.decPrivateModes.cursorBlink=void 0;else{switch(t){case 1:case 2:this._coreService.decPrivateModes.cursorStyle="block";break;case 3:case 4:this._coreService.decPrivateModes.cursorStyle="underline";break;case 5:case 6:this._coreService.decPrivateModes.cursorStyle="bar"}const e=t%2==1;this._coreService.decPrivateModes.cursorBlink=e}return!0}setScrollRegion(e){const t=e.params[0]||1;let i;return(e.length<2||(i=e.params[1])>this._bufferService.rows||0===i)&&(i=this._bufferService.rows),i>t&&(this._activeBuffer.scrollTop=t-1,this._activeBuffer.scrollBottom=i-1,this._setCursor(0,0)),!0}windowOptions(e){if(!y(e.params[0],this._optionsService.rawOptions.windowOptions))return!0;const t=e.length>1?e.params[1]:0;switch(e.params[0]){case 14:2!==t&&this._onRequestWindowsOptionsReport.fire(w.GET_WIN_SIZE_PIXELS);break;case 16:this._onRequestWindowsOptionsReport.fire(w.GET_CELL_SIZE_PIXELS);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(`${n.C0.ESC}[8;${this._bufferService.rows};${this._bufferService.cols}t`);break;case 22:0!==t&&2!==t||(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>10&&this._windowTitleStack.shift()),0!==t&&1!==t||(this._iconNameStack.push(this._iconName),this._iconNameStack.length>10&&this._iconNameStack.shift());break;case 23:0!==t&&2!==t||this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),0!==t&&1!==t||this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop())}return!0}saveCursor(e){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,!0}restoreCursor(e){return this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg,this._charsetService.charset=this._savedCharset,this._activeBuffer.savedCharset&&(this._charsetService.charset=this._activeBuffer.savedCharset),this._restrictCursor(),!0}setTitle(e){return this._windowTitle=e,this._onTitleChange.fire(e),!0}setIconName(e){return this._iconName=e,!0}setOrReportIndexedColor(e){const t=[],i=e.split(";");for(;i.length>1;){const e=i.shift(),s=i.shift();if(/^\d+$/.exec(e)){const i=parseInt(e);if(R(i))if("?"===s)t.push({type:0,index:i});else{const e=(0,v.parseColor)(s);e&&t.push({type:1,index:i,color:e})}}}return t.length&&this._onColor.fire(t),!0}setHyperlink(e){const t=e.indexOf(";");if(-1===t)return!0;const i=e.slice(0,t).trim(),s=e.slice(t+1);return s?this._createHyperlink(i,s):!i.trim()&&this._finishHyperlink()}_createHyperlink(e,t){this._getCurrentLinkId()&&this._finishHyperlink();const i=e.split(":");let s;const r=i.findIndex((e=>e.startsWith("id=")));return-1!==r&&(s=i[r].slice(3)||void 0),this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=this._oscLinkService.registerLink({id:s,uri:t}),this._curAttrData.updateExtended(),!0}_finishHyperlink(){return this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=0,this._curAttrData.updateExtended(),!0}_setOrReportSpecialColor(e,t){const i=e.split(";");for(let e=0;e=this._specialColors.length);++e,++t)if("?"===i[e])this._onColor.fire([{type:0,index:this._specialColors[t]}]);else{const s=(0,v.parseColor)(i[e]);s&&this._onColor.fire([{type:1,index:this._specialColors[t],color:s}])}return!0}setOrReportFgColor(e){return this._setOrReportSpecialColor(e,0)}setOrReportBgColor(e){return this._setOrReportSpecialColor(e,1)}setOrReportCursorColor(e){return this._setOrReportSpecialColor(e,2)}restoreIndexedColor(e){if(!e)return this._onColor.fire([{type:2}]),!0;const t=[],i=e.split(";");for(let e=0;e=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0}tabSet(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0}reverseIndex(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){const e=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,e,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0}fullReset(){return this._parser.reset(),this._onRequestReset.fire(),!0}reset(){this._curAttrData=c.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=c.DEFAULT_ATTR_DATA.clone()}_eraseAttrData(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=67108863&this._curAttrData.bg,this._eraseAttrDataInternal}setgLevel(e){return this._charsetService.setgLevel(e),!0}screenAlignmentPattern(){const e=new u.CellData;e.content=1<<22|"E".charCodeAt(0),e.fg=this._curAttrData.fg,e.bg=this._curAttrData.bg,this._setCursor(0,0);for(let t=0;t(this._coreService.triggerDataEvent(`${n.C0.ESC}${e}${n.C0.ESC}\\`),!0))('"q'===e?`P1$r${this._curAttrData.isProtected()?1:0}"q`:'"p'===e?'P1$r61;1"p':"r"===e?`P1$r${i.scrollTop+1};${i.scrollBottom+1}r`:"m"===e?"P1$r0m":" q"===e?`P1$r${{block:2,underline:4,bar:6}[s.cursorStyle]-(s.cursorBlink?1:0)} q`:"P0$r")}markRangeDirty(e,t){this._dirtyRowTracker.markRangeDirty(e,t)}}t.InputHandler=D;let L=class{constructor(e){this._bufferService=e,this.clearRange()}clearRange(){this.start=this._bufferService.buffer.y,this.end=this._bufferService.buffer.y}markDirty(e){ethis.end&&(this.end=e)}markRangeDirty(e,t){e>t&&(E=e,e=t,t=E),ethis.end&&(this.end=t)}markAllDirty(){this.markRangeDirty(0,this._bufferService.rows-1)}};function R(e){return 0<=e&&e<256}L=s([r(0,f.IBufferService)],L)},7710:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.FourKeyMap=t.TwoKeyMap=void 0;class i{constructor(){this._data={}}set(e,t,i){this._data[e]||(this._data[e]={}),this._data[e][t]=i}get(e,t){return this._data[e]?this._data[e][t]:void 0}clear(){this._data={}}}t.TwoKeyMap=i,t.FourKeyMap=class{constructor(){this._data=new i}set(e,t,s,r,n){this._data.get(e,t)||this._data.set(e,t,new i),this._data.get(e,t).set(s,r,n)}get(e,t,i,s){return this._data.get(e,t)?.get(i,s)}clear(){this._data.clear()}}},701:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.isChromeOS=t.isLinux=t.isWindows=t.isIphone=t.isIpad=t.isMac=t.isSafari=t.isLegacyEdge=t.isFirefox=t.isNode=void 0,t.getSafariVersion=function(){if(!t.isSafari)return 0;const e=i.match(/Version\/(\d+)/);return null===e||e.length<2?0:parseInt(e[1])},t.isNode="undefined"!=typeof process&&"title"in process;const i=t.isNode?"node":navigator.userAgent,s=t.isNode?"node":navigator.platform;t.isFirefox=i.includes("Firefox"),t.isLegacyEdge=i.includes("Edge"),t.isSafari=/^((?!chrome|android).)*safari/i.test(i),t.isMac=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(s),t.isIpad="iPad"===s,t.isIphone="iPhone"===s,t.isWindows=["Windows","Win16","Win32","WinCE"].includes(s),t.isLinux=s.indexOf("Linux")>=0,t.isChromeOS=/\bCrOS\b/.test(i)},3087:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SortedList=void 0;const s=i(6168);let r=0;t.SortedList=class{constructor(e){this._getKey=e,this._array=[],this._insertedValues=[],this._flushInsertedTask=new s.IdleTaskQueue,this._isFlushingInserted=!1,this._deletedIndices=[],this._flushDeletedTask=new s.IdleTaskQueue,this._isFlushingDeleted=!1}clear(){this._array.length=0,this._insertedValues.length=0,this._flushInsertedTask.clear(),this._isFlushingInserted=!1,this._deletedIndices.length=0,this._flushDeletedTask.clear(),this._isFlushingDeleted=!1}insert(e){this._flushCleanupDeleted(),0===this._insertedValues.length&&this._flushInsertedTask.enqueue((()=>this._flushInserted())),this._insertedValues.push(e)}_flushInserted(){const e=this._insertedValues.sort(((e,t)=>this._getKey(e)-this._getKey(t)));let t=0,i=0;const s=new Array(this._array.length+this._insertedValues.length);for(let r=0;r=this._array.length||this._getKey(e[t])<=this._getKey(this._array[i])?(s[r]=e[t],t++):s[r]=this._array[i++];this._array=s,this._insertedValues.length=0}_flushCleanupInserted(){!this._isFlushingInserted&&this._insertedValues.length>0&&this._flushInsertedTask.flush()}delete(e){if(this._flushCleanupInserted(),0===this._array.length)return!1;const t=this._getKey(e);if(void 0===t)return!1;if(r=this._search(t),-1===r)return!1;if(this._getKey(this._array[r])!==t)return!1;do{if(this._array[r]===e)return 0===this._deletedIndices.length&&this._flushDeletedTask.enqueue((()=>this._flushDeleted())),this._deletedIndices.push(r),!0}while(++re-t));let t=0;const i=new Array(this._array.length-e.length);let s=0;for(let r=0;r0&&this._flushDeletedTask.flush()}*getKeyIterator(e){if(this._flushCleanupInserted(),this._flushCleanupDeleted(),0!==this._array.length&&(r=this._search(e),!(r<0||r>=this._array.length)&&this._getKey(this._array[r])===e))do{yield this._array[r]}while(++r=this._array.length)&&this._getKey(this._array[r])===e))do{t(this._array[r])}while(++r=t;){let s=t+i>>1;const r=this._getKey(this._array[s]);if(r>e)i=s-1;else{if(!(r0&&this._getKey(this._array[s-1])===e;)s--;return s}t=s+1}}return t}}},6168:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DebouncedIdleTask=t.IdleTaskQueue=t.PriorityTaskQueue=void 0;const s=i(701);class r{constructor(){this._tasks=[],this._i=0}enqueue(e){this._tasks.push(e),this._start()}flush(){for(;this._ir)return s-t<-20&&console.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(s-t))}ms`),void this._start();s=r}this.clear()}}class n extends r{_requestCallback(e){return setTimeout((()=>e(this._createDeadline(16))))}_cancelCallback(e){clearTimeout(e)}_createDeadline(e){const t=performance.now()+e;return{timeRemaining:()=>Math.max(0,t-performance.now())}}}t.PriorityTaskQueue=n,t.IdleTaskQueue=!s.isNode&&"requestIdleCallback"in window?class extends r{_requestCallback(e){return requestIdleCallback(e)}_cancelCallback(e){cancelIdleCallback(e)}}:n,t.DebouncedIdleTask=class{constructor(){this._queue=new t.IdleTaskQueue}set(e){this._queue.clear(),this._queue.enqueue(e)}flush(){this._queue.flush()}}},5882:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.updateWindowsModeWrappedState=function(e){const t=e.buffer.lines.get(e.buffer.ybase+e.buffer.y-1),i=t?.get(e.cols-1),r=e.buffer.lines.get(e.buffer.ybase+e.buffer.y);r&&i&&(r.isWrapped=i[s.CHAR_DATA_CODE_INDEX]!==s.NULL_CELL_CODE&&i[s.CHAR_DATA_CODE_INDEX]!==s.WHITESPACE_CELL_CODE)};const s=i(8938)},5451:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ExtendedAttrs=t.AttributeData=void 0;class i{constructor(){this.fg=0,this.bg=0,this.extended=new s}static toColorRGB(e){return[e>>>16&255,e>>>8&255,255&e]}static fromColorRGB(e){return(255&e[0])<<16|(255&e[1])<<8|255&e[2]}clone(){const e=new i;return e.fg=this.fg,e.bg=this.bg,e.extended=this.extended.clone(),e}isInverse(){return 67108864&this.fg}isBold(){return 134217728&this.fg}isUnderline(){return this.hasExtendedAttrs()&&0!==this.extended.underlineStyle?1:268435456&this.fg}isBlink(){return 536870912&this.fg}isInvisible(){return 1073741824&this.fg}isItalic(){return 67108864&this.bg}isDim(){return 134217728&this.bg}isStrikethrough(){return 2147483648&this.fg}isProtected(){return 536870912&this.bg}isOverline(){return 1073741824&this.bg}getFgColorMode(){return 50331648&this.fg}getBgColorMode(){return 50331648&this.bg}isFgRGB(){return!(50331648&~this.fg)}isBgRGB(){return!(50331648&~this.bg)}isFgPalette(){return 16777216==(50331648&this.fg)||33554432==(50331648&this.fg)}isBgPalette(){return 16777216==(50331648&this.bg)||33554432==(50331648&this.bg)}isFgDefault(){return!(50331648&this.fg)}isBgDefault(){return!(50331648&this.bg)}isAttributeDefault(){return 0===this.fg&&0===this.bg}getFgColor(){switch(50331648&this.fg){case 16777216:case 33554432:return 255&this.fg;case 50331648:return 16777215&this.fg;default:return-1}}getBgColor(){switch(50331648&this.bg){case 16777216:case 33554432:return 255&this.bg;case 50331648:return 16777215&this.bg;default:return-1}}hasExtendedAttrs(){return 268435456&this.bg}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(268435456&this.bg&&~this.extended.underlineColor)switch(50331648&this.extended.underlineColor){case 16777216:case 33554432:return 255&this.extended.underlineColor;case 50331648:return 16777215&this.extended.underlineColor;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return 268435456&this.bg&&~this.extended.underlineColor?50331648&this.extended.underlineColor:this.getFgColorMode()}isUnderlineColorRGB(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&~this.extended.underlineColor):this.isFgRGB()}isUnderlineColorPalette(){return 268435456&this.bg&&~this.extended.underlineColor?16777216==(50331648&this.extended.underlineColor)||33554432==(50331648&this.extended.underlineColor):this.isFgPalette()}isUnderlineColorDefault(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&this.extended.underlineColor):this.isFgDefault()}getUnderlineStyle(){return 268435456&this.fg?268435456&this.bg?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}}t.AttributeData=i;class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(e){this._ext&=-67108864,this._ext|=67108863&e}get urlId(){return this._urlId}set urlId(e){this._urlId=e}get underlineVariantOffset(){const e=(3758096384&this._ext)>>29;return e<0?4294967288^e:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}constructor(e=0,t=0){this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}clone(){return new s(this._ext,this._urlId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId}}t.ExtendedAttrs=s},1073:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Buffer=t.MAX_BUFFER_SIZE=void 0;const s=i(5639),r=i(6168),n=i(5451),o=i(6107),a=i(732),l=i(3055),h=i(8938),c=i(8158),d=i(6760);t.MAX_BUFFER_SIZE=4294967295,t.Buffer=class{constructor(e,t,i){this._hasScrollback=e,this._optionsService=t,this._bufferService=i,this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.tabs={},this.savedY=0,this.savedX=0,this.savedCurAttrData=o.DEFAULT_ATTR_DATA.clone(),this.savedCharset=d.DEFAULT_CHARSET,this.markers=[],this._nullCell=l.CellData.fromCharData([0,h.NULL_CELL_CHAR,h.NULL_CELL_WIDTH,h.NULL_CELL_CODE]),this._whitespaceCell=l.CellData.fromCharData([0,h.WHITESPACE_CELL_CHAR,h.WHITESPACE_CELL_WIDTH,h.WHITESPACE_CELL_CODE]),this._isClearing=!1,this._memoryCleanupQueue=new r.IdleTaskQueue,this._memoryCleanupPosition=0,this._cols=this._bufferService.cols,this._rows=this._bufferService.rows,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}getNullCell(e){return e?(this._nullCell.fg=e.fg,this._nullCell.bg=e.bg,this._nullCell.extended=e.extended):(this._nullCell.fg=0,this._nullCell.bg=0,this._nullCell.extended=new n.ExtendedAttrs),this._nullCell}getWhitespaceCell(e){return e?(this._whitespaceCell.fg=e.fg,this._whitespaceCell.bg=e.bg,this._whitespaceCell.extended=e.extended):(this._whitespaceCell.fg=0,this._whitespaceCell.bg=0,this._whitespaceCell.extended=new n.ExtendedAttrs),this._whitespaceCell}getBlankLine(e,t){return new o.BufferLine(this._bufferService.cols,this.getNullCell(e),t)}get hasScrollback(){return this._hasScrollback&&this.lines.maxLength>this._rows}get isCursorInViewport(){const e=this.ybase+this.y-this.ydisp;return e>=0&&et.MAX_BUFFER_SIZE?t.MAX_BUFFER_SIZE:i}fillViewportRows(e){if(0===this.lines.length){void 0===e&&(e=o.DEFAULT_ATTR_DATA);let t=this._rows;for(;t--;)this.lines.push(this.getBlankLine(e))}}clear(){this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}resize(e,t){const i=this.getNullCell(o.DEFAULT_ATTR_DATA);let s=0;const r=this._getCorrectBufferLength(t);if(r>this.lines.maxLength&&(this.lines.maxLength=r),this.lines.length>0){if(this._cols0&&this.lines.length<=this.ybase+this.y+n+1?(this.ybase--,n++,this.ydisp>0&&this.ydisp--):this.lines.push(new o.BufferLine(e,i)));else for(let e=this._rows;e>t;e--)this.lines.length>t+this.ybase&&(this.lines.length>this.ybase+this.y+1?this.lines.pop():(this.ybase++,this.ydisp++));if(r0&&(this.lines.trimStart(e),this.ybase=Math.max(this.ybase-e,0),this.ydisp=Math.max(this.ydisp-e,0),this.savedY=Math.max(this.savedY-e,0)),this.lines.maxLength=r}this.x=Math.min(this.x,e-1),this.y=Math.min(this.y,t-1),n&&(this.y+=n),this.savedX=Math.min(this.savedX,e-1),this.scrollTop=0}if(this.scrollBottom=t-1,this._isReflowEnabled&&(this._reflow(e,t),this._cols>e))for(let t=0;t.1*this.lines.length&&(this._memoryCleanupPosition=0,this._memoryCleanupQueue.enqueue((()=>this._batchedMemoryCleanup())))}_batchedMemoryCleanup(){let e=!0;this._memoryCleanupPosition>=this.lines.length&&(this._memoryCleanupPosition=0,e=!1);let t=0;for(;this._memoryCleanupPosition100)return!0;return e}get _isReflowEnabled(){const e=this._optionsService.rawOptions.windowsPty;return e&&e.buildNumber?this._hasScrollback&&"conpty"===e.backend&&e.buildNumber>=21376:this._hasScrollback&&!this._optionsService.rawOptions.windowsMode}_reflow(e,t){this._cols!==e&&(e>this._cols?this._reflowLarger(e,t):this._reflowSmaller(e,t))}_reflowLarger(e,t){const i=this._optionsService.rawOptions.reflowCursorLine,s=(0,a.reflowLargerGetLinesToRemove)(this.lines,this._cols,e,this.ybase+this.y,this.getNullCell(o.DEFAULT_ATTR_DATA),i);if(s.length>0){const i=(0,a.reflowLargerCreateNewLayout)(this.lines,s);(0,a.reflowLargerApplyNewLayout)(this.lines,i.layout),this._reflowLargerAdjustViewport(e,t,i.countRemoved)}}_reflowLargerAdjustViewport(e,t,i){const s=this.getNullCell(o.DEFAULT_ATTR_DATA);let r=i;for(;r-- >0;)0===this.ybase?(this.y>0&&this.y--,this.lines.length=0;l--){let h=this.lines.get(l);if(!h||!h.isWrapped&&h.getTrimmedLength()<=e)continue;const c=[h];for(;h.isWrapped&&l>0;)h=this.lines.get(--l),c.unshift(h);if(!i){const e=this.ybase+this.y;if(e>=l&&e0&&(r.push({start:l+c.length+n,newLines:p}),n+=p.length),c.push(...p);let g=u.length-1,m=u[g];0===m&&(g--,m=u[g]);let v=c.length-_-1,S=d;for(;v>=0;){const e=Math.min(S,m);if(void 0===c[g])break;if(c[g].copyCellsFrom(c[v],S-e,m-e,e,!0),m-=e,0===m&&(g--,m=u[g]),S-=e,0===S){v--;const e=Math.max(v,0);S=(0,a.getWrappedLineTrimmedLength)(c,e,this._cols)}}for(let t=0;t0;)0===this.ybase?this.y0){const e=[],t=[];for(let e=0;e=0;h--)if(a&&a.start>s+l){for(let e=a.newLines.length-1;e>=0;e--)this.lines.set(h--,a.newLines[e]);h++,e.push({index:s+1,amount:a.newLines.length}),l+=a.newLines.length,a=r[++o]}else this.lines.set(h,t[s--]);let h=0;for(let t=e.length-1;t>=0;t--)e[t].index+=h,this.lines.onInsertEmitter.fire(e[t]),h+=e[t].amount;const c=Math.max(0,i+n-this.lines.maxLength);c>0&&this.lines.onTrimEmitter.fire(c)}}translateBufferLineToString(e,t,i=0,s){const r=this.lines.get(e);return r?r.translateToString(t,i,s):""}getWrappedRangeForLine(e){let t=e,i=e;for(;t>0&&this.lines.get(t).isWrapped;)t--;for(;i+10;);return e>=this._cols?this._cols-1:e<0?0:e}nextStop(e){for(null==e&&(e=this.x);!this.tabs[++e]&&e=this._cols?this._cols-1:e<0?0:e}clearMarkers(e){this._isClearing=!0;for(let t=0;t{t.line-=e,t.line<0&&t.dispose()}))),t.register(this.lines.onInsert((e=>{t.line>=e.index&&(t.line+=e.amount)}))),t.register(this.lines.onDelete((e=>{t.line>=e.index&&t.linee.index&&(t.line-=e.amount)}))),t.register(t.onDispose((()=>this._removeMarker(t)))),t}_removeMarker(e){this._isClearing||this.markers.splice(this.markers.indexOf(e),1)}}},6107:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLine=t.DEFAULT_ATTR_DATA=void 0;const s=i(5451),r=i(3055),n=i(8938),o=i(726);t.DEFAULT_ATTR_DATA=Object.freeze(new s.AttributeData);let a=0;class l{constructor(e,t,i=!1){this.isWrapped=i,this._combined={},this._extendedAttrs={},this._data=new Uint32Array(3*e);const s=t||r.CellData.fromCharData([0,n.NULL_CELL_CHAR,n.NULL_CELL_WIDTH,n.NULL_CELL_CODE]);for(let t=0;t>22,2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):i]}set(e,t){this._data[3*e+1]=t[n.CHAR_DATA_ATTR_INDEX],t[n.CHAR_DATA_CHAR_INDEX].length>1?(this._combined[e]=t[1],this._data[3*e+0]=2097152|e|t[n.CHAR_DATA_WIDTH_INDEX]<<22):this._data[3*e+0]=t[n.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|t[n.CHAR_DATA_WIDTH_INDEX]<<22}getWidth(e){return this._data[3*e+0]>>22}hasWidth(e){return 12582912&this._data[3*e+0]}getFg(e){return this._data[3*e+1]}getBg(e){return this._data[3*e+2]}hasContent(e){return 4194303&this._data[3*e+0]}getCodePoint(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):2097151&t}isCombined(e){return 2097152&this._data[3*e+0]}getString(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e]:2097151&t?(0,o.stringFromCodePoint)(2097151&t):""}isProtected(e){return 536870912&this._data[3*e+2]}loadCell(e,t){return a=3*e,t.content=this._data[a+0],t.fg=this._data[a+1],t.bg=this._data[a+2],2097152&t.content&&(t.combinedData=this._combined[e]),268435456&t.bg&&(t.extended=this._extendedAttrs[e]),t}setCell(e,t){2097152&t.content&&(this._combined[e]=t.combinedData),268435456&t.bg&&(this._extendedAttrs[e]=t.extended),this._data[3*e+0]=t.content,this._data[3*e+1]=t.fg,this._data[3*e+2]=t.bg}setCellFromCodepoint(e,t,i,s){268435456&s.bg&&(this._extendedAttrs[e]=s.extended),this._data[3*e+0]=t|i<<22,this._data[3*e+1]=s.fg,this._data[3*e+2]=s.bg}addCodepointToCell(e,t,i){let s=this._data[3*e+0];2097152&s?this._combined[e]+=(0,o.stringFromCodePoint)(t):2097151&s?(this._combined[e]=(0,o.stringFromCodePoint)(2097151&s)+(0,o.stringFromCodePoint)(t),s&=-2097152,s|=2097152):s=t|1<<22,i&&(s&=-12582913,s|=i<<22),this._data[3*e+0]=s}insertCells(e,t,i){if((e%=this.length)&&2===this.getWidth(e-1)&&this.setCellFromCodepoint(e-1,0,1,i),t=0;--i)this.setCell(e+t+i,this.loadCell(e+i,s));for(let s=0;sthis.length){if(this._data.buffer.byteLength>=4*i)this._data=new Uint32Array(this._data.buffer,0,i);else{const e=new Uint32Array(i);e.set(this._data),this._data=e}for(let i=this.length;i=e&&delete this._combined[s]}const s=Object.keys(this._extendedAttrs);for(let t=0;t=e&&delete this._extendedAttrs[i]}}return this.length=e,4*i*2=0;--e)if(4194303&this._data[3*e+0])return e+(this._data[3*e+0]>>22);return 0}getNoBgTrimmedLength(){for(let e=this.length-1;e>=0;--e)if(4194303&this._data[3*e+0]||50331648&this._data[3*e+2])return e+(this._data[3*e+0]>>22);return 0}copyCellsFrom(e,t,i,s,r){const n=e._data;if(r)for(let r=s-1;r>=0;r--){for(let e=0;e<3;e++)this._data[3*(i+r)+e]=n[3*(t+r)+e];268435456&n[3*(t+r)+2]&&(this._extendedAttrs[i+r]=e._extendedAttrs[t+r])}else for(let r=0;r=t&&(this._combined[r-t+i]=e._combined[r])}}translateToString(e,t,i,s){t=t??0,i=i??this.length,e&&(i=Math.min(i,this.getTrimmedLength())),s&&(s.length=0);let r="";for(;t>22||1}return s&&s.push(t),r}}t.BufferLine=l},9384:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.getRangeLength=function(e,t){if(e.start.y>e.end.y)throw new Error(`Buffer range end (${e.end.x}, ${e.end.y}) cannot be before start (${e.start.x}, ${e.start.y})`);return t*(e.end.y-e.start.y)+(e.end.x-e.start.x+1)}},732:(e,t)=>{function i(e,t,i){if(t===e.length-1)return e[t].getTrimmedLength();const s=!e[t].hasContent(i-1)&&1===e[t].getWidth(i-1),r=2===e[t+1].getWidth(0);return s&&r?i-1:i}Object.defineProperty(t,"__esModule",{value:!0}),t.reflowLargerGetLinesToRemove=function(e,t,s,r,n,o){const a=[];for(let l=0;l=l&&r0&&(e>u||0===d[e].getTrimmedLength());e--)g++;g>0&&(a.push(l+d.length-g),a.push(g)),l+=d.length-1}return a},t.reflowLargerCreateNewLayout=function(e,t){const i=[];let s=0,r=t[s],n=0;for(let o=0;oi(e,r,t))).reduce(((e,t)=>e+t));let o=0,a=0,l=0;for(;lh&&(o-=h,a++);const c=2===e[a].getWidth(o-1);c&&o--;const d=c?s-1:s;r.push(d),l+=d}return r},t.getWrappedLineTrimmedLength=i},4097:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferSet=void 0;const s=i(7150),r=i(1073),n=i(802);class o extends s.Disposable{constructor(e,t){super(),this._optionsService=e,this._bufferService=t,this._onBufferActivate=this._register(new n.Emitter),this.onBufferActivate=this._onBufferActivate.event,this.reset(),this._register(this._optionsService.onSpecificOptionChange("scrollback",(()=>this.resize(this._bufferService.cols,this._bufferService.rows)))),this._register(this._optionsService.onSpecificOptionChange("tabStopWidth",(()=>this.setupTabStops())))}reset(){this._normal=new r.Buffer(!0,this._optionsService,this._bufferService),this._normal.fillViewportRows(),this._alt=new r.Buffer(!1,this._optionsService,this._bufferService),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}),this.setupTabStops()}get alt(){return this._alt}get active(){return this._activeBuffer}get normal(){return this._normal}activateNormalBuffer(){this._activeBuffer!==this._normal&&(this._normal.x=this._alt.x,this._normal.y=this._alt.y,this._alt.clearAllMarkers(),this._alt.clear(),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}))}activateAltBuffer(e){this._activeBuffer!==this._alt&&(this._alt.fillViewportRows(e),this._alt.x=this._normal.x,this._alt.y=this._normal.y,this._activeBuffer=this._alt,this._onBufferActivate.fire({activeBuffer:this._alt,inactiveBuffer:this._normal}))}resize(e,t){this._normal.resize(e,t),this._alt.resize(e,t),this.setupTabStops(e)}setupTabStops(e){this._normal.setupTabStops(e),this._alt.setupTabStops(e)}}t.BufferSet=o},3055:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CellData=void 0;const s=i(726),r=i(8938),n=i(5451);class o extends n.AttributeData{constructor(){super(...arguments),this.content=0,this.fg=0,this.bg=0,this.extended=new n.ExtendedAttrs,this.combinedData=""}static fromCharData(e){const t=new o;return t.setFromCharData(e),t}isCombined(){return 2097152&this.content}getWidth(){return this.content>>22}getChars(){return 2097152&this.content?this.combinedData:2097151&this.content?(0,s.stringFromCodePoint)(2097151&this.content):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):2097151&this.content}setFromCharData(e){this.fg=e[r.CHAR_DATA_ATTR_INDEX],this.bg=0;let t=!1;if(e[r.CHAR_DATA_CHAR_INDEX].length>2)t=!0;else if(2===e[r.CHAR_DATA_CHAR_INDEX].length){const i=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0);if(55296<=i&&i<=56319){const s=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(1);56320<=s&&s<=57343?this.content=1024*(i-55296)+s-56320+65536|e[r.CHAR_DATA_WIDTH_INDEX]<<22:t=!0}else t=!0}else this.content=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|e[r.CHAR_DATA_WIDTH_INDEX]<<22;t&&(this.combinedData=e[r.CHAR_DATA_CHAR_INDEX],this.content=2097152|e[r.CHAR_DATA_WIDTH_INDEX]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.CellData=o},8938:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.WHITESPACE_CELL_CODE=t.WHITESPACE_CELL_WIDTH=t.WHITESPACE_CELL_CHAR=t.NULL_CELL_CODE=t.NULL_CELL_WIDTH=t.NULL_CELL_CHAR=t.CHAR_DATA_CODE_INDEX=t.CHAR_DATA_WIDTH_INDEX=t.CHAR_DATA_CHAR_INDEX=t.CHAR_DATA_ATTR_INDEX=t.DEFAULT_EXT=t.DEFAULT_ATTR=t.DEFAULT_COLOR=void 0,t.DEFAULT_COLOR=0,t.DEFAULT_ATTR=t.DEFAULT_COLOR<<9|256,t.DEFAULT_EXT=0,t.CHAR_DATA_ATTR_INDEX=0,t.CHAR_DATA_CHAR_INDEX=1,t.CHAR_DATA_WIDTH_INDEX=2,t.CHAR_DATA_CODE_INDEX=3,t.NULL_CELL_CHAR="",t.NULL_CELL_WIDTH=1,t.NULL_CELL_CODE=0,t.WHITESPACE_CELL_CHAR=" ",t.WHITESPACE_CELL_WIDTH=1,t.WHITESPACE_CELL_CODE=32},8158:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Marker=void 0;const s=i(802),r=i(7150);class n{get id(){return this._id}constructor(e){this.line=e,this.isDisposed=!1,this._disposables=[],this._id=n._nextId++,this._onDispose=this.register(new s.Emitter),this.onDispose=this._onDispose.event}dispose(){this.isDisposed||(this.isDisposed=!0,this.line=-1,this._onDispose.fire(),(0,r.dispose)(this._disposables),this._disposables.length=0)}register(e){return this._disposables.push(e),e}}t.Marker=n,n._nextId=1},6760:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_CHARSET=t.CHARSETS=void 0,t.CHARSETS={},t.DEFAULT_CHARSET=t.CHARSETS.B,t.CHARSETS[0]={"`":"◆",a:"▒",b:"␉",c:"␌",d:"␍",e:"␊",f:"°",g:"±",h:"␤",i:"␋",j:"┘",k:"┐",l:"┌",m:"└",n:"┼",o:"⎺",p:"⎻",q:"─",r:"⎼",s:"⎽",t:"├",u:"┤",v:"┴",w:"┬",x:"│",y:"≤",z:"≥","{":"π","|":"≠","}":"£","~":"·"},t.CHARSETS.A={"#":"£"},t.CHARSETS.B=void 0,t.CHARSETS[4]={"#":"£","@":"¾","[":"ij","\\":"½","]":"|","{":"¨","|":"f","}":"¼","~":"´"},t.CHARSETS.C=t.CHARSETS[5]={"[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS.R={"#":"£","@":"à","[":"°","\\":"ç","]":"§","{":"é","|":"ù","}":"è","~":"¨"},t.CHARSETS.Q={"@":"à","[":"â","\\":"ç","]":"ê","^":"î","`":"ô","{":"é","|":"ù","}":"è","~":"û"},t.CHARSETS.K={"@":"§","[":"Ä","\\":"Ö","]":"Ü","{":"ä","|":"ö","}":"ü","~":"ß"},t.CHARSETS.Y={"#":"£","@":"§","[":"°","\\":"ç","]":"é","`":"ù","{":"à","|":"ò","}":"è","~":"ì"},t.CHARSETS.E=t.CHARSETS[6]={"@":"Ä","[":"Æ","\\":"Ø","]":"Å","^":"Ü","`":"ä","{":"æ","|":"ø","}":"å","~":"ü"},t.CHARSETS.Z={"#":"£","@":"§","[":"¡","\\":"Ñ","]":"¿","{":"°","|":"ñ","}":"ç"},t.CHARSETS.H=t.CHARSETS[7]={"@":"É","[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS["="]={"#":"ù","@":"à","[":"é","\\":"ç","]":"ê","^":"î",_:"è","`":"ô","{":"ä","|":"ö","}":"ü","~":"û"}},3534:(e,t)=>{var i,s,r;Object.defineProperty(t,"__esModule",{value:!0}),t.C1_ESCAPED=t.C1=t.C0=void 0,function(e){e.NUL="\0",e.SOH="",e.STX="",e.ETX="",e.EOT="",e.ENQ="",e.ACK="",e.BEL="",e.BS="\b",e.HT="\t",e.LF="\n",e.VT="\v",e.FF="\f",e.CR="\r",e.SO="",e.SI="",e.DLE="",e.DC1="",e.DC2="",e.DC3="",e.DC4="",e.NAK="",e.SYN="",e.ETB="",e.CAN="",e.EM="",e.SUB="",e.ESC="",e.FS="",e.GS="",e.RS="",e.US="",e.SP=" ",e.DEL=""}(i||(t.C0=i={})),function(e){e.PAD="€",e.HOP="",e.BPH="‚",e.NBH="ƒ",e.IND="„",e.NEL="…",e.SSA="†",e.ESA="‡",e.HTS="ˆ",e.HTJ="‰",e.VTS="Š",e.PLD="‹",e.PLU="Œ",e.RI="",e.SS2="Ž",e.SS3="",e.DCS="",e.PU1="‘",e.PU2="’",e.STS="“",e.CCH="”",e.MW="•",e.SPA="–",e.EPA="—",e.SOS="˜",e.SGCI="™",e.SCI="š",e.CSI="›",e.ST="œ",e.OSC="",e.PM="ž",e.APC="Ÿ"}(s||(t.C1=s={})),function(e){e.ST=`${i.ESC}\\`}(r||(t.C1_ESCAPED=r={}))},706:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.evaluateKeyboardEvent=function(e,t,i,n){const o={type:0,cancel:!1,key:void 0},a=(e.shiftKey?1:0)|(e.altKey?2:0)|(e.ctrlKey?4:0)|(e.metaKey?8:0);switch(e.keyCode){case 0:"UIKeyInputUpArrow"===e.key?o.key=t?s.C0.ESC+"OA":s.C0.ESC+"[A":"UIKeyInputLeftArrow"===e.key?o.key=t?s.C0.ESC+"OD":s.C0.ESC+"[D":"UIKeyInputRightArrow"===e.key?o.key=t?s.C0.ESC+"OC":s.C0.ESC+"[C":"UIKeyInputDownArrow"===e.key&&(o.key=t?s.C0.ESC+"OB":s.C0.ESC+"[B");break;case 8:o.key=e.ctrlKey?"\b":s.C0.DEL,e.altKey&&(o.key=s.C0.ESC+o.key);break;case 9:if(e.shiftKey){o.key=s.C0.ESC+"[Z";break}o.key=s.C0.HT,o.cancel=!0;break;case 13:o.key=e.altKey?s.C0.ESC+s.C0.CR:s.C0.CR,o.cancel=!0;break;case 27:o.key=s.C0.ESC,e.altKey&&(o.key=s.C0.ESC+s.C0.ESC),o.cancel=!0;break;case 37:if(e.metaKey)break;o.key=a?s.C0.ESC+"[1;"+(a+1)+"D":t?s.C0.ESC+"OD":s.C0.ESC+"[D";break;case 39:if(e.metaKey)break;o.key=a?s.C0.ESC+"[1;"+(a+1)+"C":t?s.C0.ESC+"OC":s.C0.ESC+"[C";break;case 38:if(e.metaKey)break;o.key=a?s.C0.ESC+"[1;"+(a+1)+"A":t?s.C0.ESC+"OA":s.C0.ESC+"[A";break;case 40:if(e.metaKey)break;o.key=a?s.C0.ESC+"[1;"+(a+1)+"B":t?s.C0.ESC+"OB":s.C0.ESC+"[B";break;case 45:e.shiftKey||e.ctrlKey||(o.key=s.C0.ESC+"[2~");break;case 46:o.key=a?s.C0.ESC+"[3;"+(a+1)+"~":s.C0.ESC+"[3~";break;case 36:o.key=a?s.C0.ESC+"[1;"+(a+1)+"H":t?s.C0.ESC+"OH":s.C0.ESC+"[H";break;case 35:o.key=a?s.C0.ESC+"[1;"+(a+1)+"F":t?s.C0.ESC+"OF":s.C0.ESC+"[F";break;case 33:e.shiftKey?o.type=2:e.ctrlKey?o.key=s.C0.ESC+"[5;"+(a+1)+"~":o.key=s.C0.ESC+"[5~";break;case 34:e.shiftKey?o.type=3:e.ctrlKey?o.key=s.C0.ESC+"[6;"+(a+1)+"~":o.key=s.C0.ESC+"[6~";break;case 112:o.key=a?s.C0.ESC+"[1;"+(a+1)+"P":s.C0.ESC+"OP";break;case 113:o.key=a?s.C0.ESC+"[1;"+(a+1)+"Q":s.C0.ESC+"OQ";break;case 114:o.key=a?s.C0.ESC+"[1;"+(a+1)+"R":s.C0.ESC+"OR";break;case 115:o.key=a?s.C0.ESC+"[1;"+(a+1)+"S":s.C0.ESC+"OS";break;case 116:o.key=a?s.C0.ESC+"[15;"+(a+1)+"~":s.C0.ESC+"[15~";break;case 117:o.key=a?s.C0.ESC+"[17;"+(a+1)+"~":s.C0.ESC+"[17~";break;case 118:o.key=a?s.C0.ESC+"[18;"+(a+1)+"~":s.C0.ESC+"[18~";break;case 119:o.key=a?s.C0.ESC+"[19;"+(a+1)+"~":s.C0.ESC+"[19~";break;case 120:o.key=a?s.C0.ESC+"[20;"+(a+1)+"~":s.C0.ESC+"[20~";break;case 121:o.key=a?s.C0.ESC+"[21;"+(a+1)+"~":s.C0.ESC+"[21~";break;case 122:o.key=a?s.C0.ESC+"[23;"+(a+1)+"~":s.C0.ESC+"[23~";break;case 123:o.key=a?s.C0.ESC+"[24;"+(a+1)+"~":s.C0.ESC+"[24~";break;default:if(!e.ctrlKey||e.shiftKey||e.altKey||e.metaKey)if(i&&!n||!e.altKey||e.metaKey)!i||e.altKey||e.ctrlKey||e.shiftKey||!e.metaKey?e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.keyCode>=48&&1===e.key.length?o.key=e.key:e.key&&e.ctrlKey&&("_"===e.key&&(o.key=s.C0.US),"@"===e.key&&(o.key=s.C0.NUL)):65===e.keyCode&&(o.type=1);else{const t=r[e.keyCode],i=t?.[e.shiftKey?1:0];if(i)o.key=s.C0.ESC+i;else if(e.keyCode>=65&&e.keyCode<=90){const t=e.ctrlKey?e.keyCode-64:e.keyCode+32;let i=String.fromCharCode(t);e.shiftKey&&(i=i.toUpperCase()),o.key=s.C0.ESC+i}else if(32===e.keyCode)o.key=s.C0.ESC+(e.ctrlKey?s.C0.NUL:" ");else if("Dead"===e.key&&e.code.startsWith("Key")){let t=e.code.slice(3,4);e.shiftKey||(t=t.toLowerCase()),o.key=s.C0.ESC+t,o.cancel=!0}}else e.keyCode>=65&&e.keyCode<=90?o.key=String.fromCharCode(e.keyCode-64):32===e.keyCode?o.key=s.C0.NUL:e.keyCode>=51&&e.keyCode<=55?o.key=String.fromCharCode(e.keyCode-51+27):56===e.keyCode?o.key=s.C0.DEL:219===e.keyCode?o.key=s.C0.ESC:220===e.keyCode?o.key=s.C0.FS:221===e.keyCode&&(o.key=s.C0.GS)}return o};const s=i(3534),r={48:["0",")"],49:["1","!"],50:["2","@"],51:["3","#"],52:["4","$"],53:["5","%"],54:["6","^"],55:["7","&"],56:["8","*"],57:["9","("],186:[";",":"],187:["=","+"],188:[",","<"],189:["-","_"],190:[".",">"],191:["/","?"],192:["`","~"],219:["[","{"],220:["\\","|"],221:["]","}"],222:["'",'"']}},726:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Utf8ToUtf32=t.StringToUtf32=void 0,t.stringFromCodePoint=function(e){return e>65535?(e-=65536,String.fromCharCode(55296+(e>>10))+String.fromCharCode(e%1024+56320)):String.fromCharCode(e)},t.utf32ToString=function(e,t=0,i=e.length){let s="";for(let r=t;r65535?(t-=65536,s+=String.fromCharCode(55296+(t>>10))+String.fromCharCode(t%1024+56320)):s+=String.fromCharCode(t)}return s},t.StringToUtf32=class{constructor(){this._interim=0}clear(){this._interim=0}decode(e,t){const i=e.length;if(!i)return 0;let s=0,r=0;if(this._interim){const i=e.charCodeAt(r++);56320<=i&&i<=57343?t[s++]=1024*(this._interim-55296)+i-56320+65536:(t[s++]=this._interim,t[s++]=i),this._interim=0}for(let n=r;n=i)return this._interim=r,s;const o=e.charCodeAt(n);56320<=o&&o<=57343?t[s++]=1024*(r-55296)+o-56320+65536:(t[s++]=r,t[s++]=o)}else 65279!==r&&(t[s++]=r)}return s}},t.Utf8ToUtf32=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(e,t){const i=e.length;if(!i)return 0;let s,r,n,o,a=0,l=0,h=0;if(this.interim[0]){let s=!1,r=this.interim[0];r&=192==(224&r)?31:224==(240&r)?15:7;let n,o=0;for(;(n=63&this.interim[++o])&&o<4;)r<<=6,r|=n;const l=192==(224&this.interim[0])?2:224==(240&this.interim[0])?3:4,c=l-o;for(;h=i)return 0;if(n=e[h++],128!=(192&n)){h--,s=!0;break}this.interim[o++]=n,r<<=6,r|=63&n}s||(2===l?r<128?h--:t[a++]=r:3===l?r<2048||r>=55296&&r<=57343||65279===r||(t[a++]=r):r<65536||r>1114111||(t[a++]=r)),this.interim.fill(0)}const c=i-4;let d=h;for(;d=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(l=(31&s)<<6|63&r,l<128){d--;continue}t[a++]=l}else if(224==(240&s)){if(d>=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,a;if(n=e[d++],128!=(192&n)){d--;continue}if(l=(15&s)<<12|(63&r)<<6|63&n,l<2048||l>=55296&&l<=57343||65279===l)continue;t[a++]=l}else if(240==(248&s)){if(d>=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,a;if(n=e[d++],128!=(192&n)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,this.interim[2]=n,a;if(o=e[d++],128!=(192&o)){d--;continue}if(l=(7&s)<<18|(63&r)<<12|(63&n)<<6|63&o,l<65536||l>1114111)continue;t[a++]=l}}return a}}},7428:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeV6=void 0;const s=i(6415),r=[[768,879],[1155,1158],[1160,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1539],[1552,1557],[1611,1630],[1648,1648],[1750,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2305,2306],[2364,2364],[2369,2376],[2381,2381],[2385,2388],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2672,2673],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2817,2817],[2876,2876],[2879,2879],[2881,2883],[2893,2893],[2902,2902],[2946,2946],[3008,3008],[3021,3021],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3393,3395],[3405,3405],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3769],[3771,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3984,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4146],[4150,4151],[4153,4153],[4184,4185],[4448,4607],[4959,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6157],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7616,7626],[7678,7679],[8203,8207],[8234,8238],[8288,8291],[8298,8303],[8400,8431],[12330,12335],[12441,12442],[43014,43014],[43019,43019],[43045,43046],[64286,64286],[65024,65039],[65056,65059],[65279,65279],[65529,65531]],n=[[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[917505,917505],[917536,917631],[917760,917999]];let o;t.UnicodeV6=class{constructor(){if(this.version="6",!o){o=new Uint8Array(65536),o.fill(1),o[0]=0,o.fill(0,1,32),o.fill(0,127,160),o.fill(2,4352,4448),o[9001]=2,o[9002]=2,o.fill(2,11904,42192),o[12351]=1,o.fill(2,44032,55204),o.fill(2,63744,64256),o.fill(2,65040,65050),o.fill(2,65072,65136),o.fill(2,65280,65377),o.fill(2,65504,65511);for(let e=0;et[r][1])return!1;for(;r>=s;)if(i=s+r>>1,e>t[i][1])s=i+1;else{if(!(e=131072&&e<=196605||e>=196608&&e<=262141?2:1}charProperties(e,t){let i=this.wcwidth(e),r=0===i&&0!==t;if(r){const e=s.UnicodeService.extractWidth(t);0===e?r=!1:e>i&&(i=e)}return s.UnicodeService.createPropertyValue(0,i,r)}}},3562:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.WriteBuffer=void 0;const s=i(7150),r=i(802);class n extends s.Disposable{constructor(e){super(),this._action=e,this._writeBuffer=[],this._callbacks=[],this._pendingData=0,this._bufferOffset=0,this._isSyncWriting=!1,this._syncCalls=0,this._didUserInput=!1,this._onWriteParsed=this._register(new r.Emitter),this.onWriteParsed=this._onWriteParsed.event}handleUserInput(){this._didUserInput=!0}writeSync(e,t){if(void 0!==t&&this._syncCalls>t)return void(this._syncCalls=0);if(this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(void 0),this._syncCalls++,this._isSyncWriting)return;let i;for(this._isSyncWriting=!0;i=this._writeBuffer.shift();){this._action(i);const e=this._callbacks.shift();e&&e()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}write(e,t){if(this._pendingData>5e7)throw new Error("write data discarded, use flow control to avoid losing data");if(!this._writeBuffer.length){if(this._bufferOffset=0,this._didUserInput)return this._didUserInput=!1,this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t),void this._innerWrite();setTimeout((()=>this._innerWrite()))}this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t)}_innerWrite(e=0,t=!0){const i=e||performance.now();for(;this._writeBuffer.length>this._bufferOffset;){const e=this._writeBuffer[this._bufferOffset],s=this._action(e,t);if(s){const e=e=>performance.now()-i>=12?setTimeout((()=>this._innerWrite(0,e))):this._innerWrite(i,e);return void s.catch((e=>(queueMicrotask((()=>{throw e})),Promise.resolve(!1)))).then(e)}const r=this._callbacks[this._bufferOffset];if(r&&r(),this._bufferOffset++,this._pendingData-=e.length,performance.now()-i>=12)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>50&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),setTimeout((()=>this._innerWrite()))):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()}}t.WriteBuffer=n},8693:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.parseColor=function(e){if(!e)return;let t=e.toLowerCase();if(0===t.indexOf("rgb:")){t=t.slice(4);const e=i.exec(t);if(e){const t=e[1]?15:e[4]?255:e[7]?4095:65535;return[Math.round(parseInt(e[1]||e[4]||e[7]||e[10],16)/t*255),Math.round(parseInt(e[2]||e[5]||e[8]||e[11],16)/t*255),Math.round(parseInt(e[3]||e[6]||e[9]||e[12],16)/t*255)]}}else if(0===t.indexOf("#")&&(t=t.slice(1),s.exec(t)&&[3,6,9,12].includes(t.length))){const e=t.length/3,i=[0,0,0];for(let s=0;s<3;++s){const r=parseInt(t.slice(e*s,e*s+e),16);i[s]=1===e?r<<4:2===e?r:3===e?r>>4:r>>8}return i}},t.toRgbString=function(e,t=16){const[i,s,n]=e;return`rgb:${r(i,t)}/${r(s,t)}/${r(n,t)}`};const i=/^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/,s=/^[\da-f]+$/;function r(e,t){const i=e.toString(16),s=i.length<2?"0"+i:i;switch(t){case 4:return i[0];case 8:return s;case 12:return(s+s).slice(0,3);default:return s+s}}},1263:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.PAYLOAD_LIMIT=void 0,t.PAYLOAD_LIMIT=1e7},9823:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DcsHandler=t.DcsParser=void 0;const s=i(726),r=i(7262),n=i(1263),o=[];t.DcsParser=class{constructor(){this._handlers=Object.create(null),this._active=o,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=o}registerHandler(e,t){void 0===this._handlers[e]&&(this._handlers[e]=[]);const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}reset(){if(this._active.length)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].unhook(!1);this._stack.paused=!1,this._active=o,this._ident=0}hook(e,t){if(this.reset(),this._ident=e,this._active=this._handlers[e]||o,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].hook(t);else this._handlerFb(this._ident,"HOOK",t)}put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._ident,"PUT",(0,s.utf32ToString)(e,t,i))}unhook(e,t=!0){if(this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].unhook(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].unhook(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._ident,"UNHOOK",e);this._active=o,this._ident=0}};const a=new r.Params;a.addParam(0),t.DcsHandler=class{constructor(e){this._handler=e,this._data="",this._params=a,this._hitLimit=!1}hook(e){this._params=e.length>1||e.params[0]?e.clone():a,this._data="",this._hitLimit=!1}put(e,t,i){this._hitLimit||(this._data+=(0,s.utf32ToString)(e,t,i),this._data.length>n.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))}unhook(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data,this._params),t instanceof Promise))return t.then((e=>(this._params=a,this._data="",this._hitLimit=!1,e)));return this._params=a,this._data="",this._hitLimit=!1,t}}},6717:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.EscapeSequenceParser=t.VT500_TRANSITION_TABLE=t.TransitionTable=void 0;const s=i(7150),r=i(7262),n=i(1346),o=i(9823);class a{constructor(e){this.table=new Uint8Array(e)}setDefault(e,t){this.table.fill(e<<4|t)}add(e,t,i,s){this.table[t<<8|e]=i<<4|s}addMany(e,t,i,s){for(let r=0;rt)),i=(e,i)=>t.slice(e,i),s=i(32,127),r=i(0,24);r.push(25),r.push.apply(r,i(28,32));const n=i(0,14);let o;for(o in e.setDefault(1,0),e.addMany(s,0,2,0),n)e.addMany([24,26,153,154],o,3,0),e.addMany(i(128,144),o,3,0),e.addMany(i(144,152),o,3,0),e.add(156,o,0,0),e.add(27,o,11,1),e.add(157,o,4,8),e.addMany([152,158,159],o,0,7),e.add(155,o,11,3),e.add(144,o,11,9);return e.addMany(r,0,3,0),e.addMany(r,1,3,1),e.add(127,1,0,1),e.addMany(r,8,0,8),e.addMany(r,3,3,3),e.add(127,3,0,3),e.addMany(r,4,3,4),e.add(127,4,0,4),e.addMany(r,6,3,6),e.addMany(r,5,3,5),e.add(127,5,0,5),e.addMany(r,2,3,2),e.add(127,2,0,2),e.add(93,1,4,8),e.addMany(s,8,5,8),e.add(127,8,5,8),e.addMany([156,27,24,26,7],8,6,0),e.addMany(i(28,32),8,0,8),e.addMany([88,94,95],1,0,7),e.addMany(s,7,0,7),e.addMany(r,7,0,7),e.add(156,7,0,0),e.add(127,7,0,7),e.add(91,1,11,3),e.addMany(i(64,127),3,7,0),e.addMany(i(48,60),3,8,4),e.addMany([60,61,62,63],3,9,4),e.addMany(i(48,60),4,8,4),e.addMany(i(64,127),4,7,0),e.addMany([60,61,62,63],4,0,6),e.addMany(i(32,64),6,0,6),e.add(127,6,0,6),e.addMany(i(64,127),6,0,0),e.addMany(i(32,48),3,9,5),e.addMany(i(32,48),5,9,5),e.addMany(i(48,64),5,0,6),e.addMany(i(64,127),5,7,0),e.addMany(i(32,48),4,9,5),e.addMany(i(32,48),1,9,2),e.addMany(i(32,48),2,9,2),e.addMany(i(48,127),2,10,0),e.addMany(i(48,80),1,10,0),e.addMany(i(81,88),1,10,0),e.addMany([89,90,92],1,10,0),e.addMany(i(96,127),1,10,0),e.add(80,1,11,9),e.addMany(r,9,0,9),e.add(127,9,0,9),e.addMany(i(28,32),9,0,9),e.addMany(i(32,48),9,9,12),e.addMany(i(48,60),9,8,10),e.addMany([60,61,62,63],9,9,10),e.addMany(r,11,0,11),e.addMany(i(32,128),11,0,11),e.addMany(i(28,32),11,0,11),e.addMany(r,10,0,10),e.add(127,10,0,10),e.addMany(i(28,32),10,0,10),e.addMany(i(48,60),10,8,10),e.addMany([60,61,62,63],10,0,11),e.addMany(i(32,48),10,9,12),e.addMany(r,12,0,12),e.add(127,12,0,12),e.addMany(i(28,32),12,0,12),e.addMany(i(32,48),12,9,12),e.addMany(i(48,64),12,0,11),e.addMany(i(64,127),12,12,13),e.addMany(i(64,127),10,12,13),e.addMany(i(64,127),9,12,13),e.addMany(r,13,13,13),e.addMany(s,13,13,13),e.add(127,13,0,13),e.addMany([27,156,24,26],13,14,0),e.add(l,0,2,0),e.add(l,8,5,8),e.add(l,6,0,6),e.add(l,11,0,11),e.add(l,13,13,13),e}();class h extends s.Disposable{constructor(e=t.VT500_TRANSITION_TABLE){super(),this._transitions=e,this._parseStack={state:0,handlers:[],handlerPos:0,transition:0,chunkPos:0},this.initialState=0,this.currentState=this.initialState,this._params=new r.Params,this._params.addParam(0),this._collect=0,this.precedingJoinState=0,this._printHandlerFb=(e,t,i)=>{},this._executeHandlerFb=e=>{},this._csiHandlerFb=(e,t)=>{},this._escHandlerFb=e=>{},this._errorHandlerFb=e=>e,this._printHandler=this._printHandlerFb,this._executeHandlers=Object.create(null),this._csiHandlers=Object.create(null),this._escHandlers=Object.create(null),this._register((0,s.toDisposable)((()=>{this._csiHandlers=Object.create(null),this._executeHandlers=Object.create(null),this._escHandlers=Object.create(null)}))),this._oscParser=this._register(new n.OscParser),this._dcsParser=this._register(new o.DcsParser),this._errorHandler=this._errorHandlerFb,this.registerEscHandler({final:"\\"},(()=>!0))}_identifier(e,t=[64,126]){let i=0;if(e.prefix){if(e.prefix.length>1)throw new Error("only one byte as prefix supported");if(i=e.prefix.charCodeAt(0),i&&60>i||i>63)throw new Error("prefix must be in range 0x3c .. 0x3f")}if(e.intermediates){if(e.intermediates.length>2)throw new Error("only two bytes as intermediates are supported");for(let t=0;ts||s>47)throw new Error("intermediate must be in range 0x20 .. 0x2f");i<<=8,i|=s}}if(1!==e.final.length)throw new Error("final must be a single byte");const s=e.final.charCodeAt(0);if(t[0]>s||s>t[1])throw new Error(`final must be in range ${t[0]} .. ${t[1]}`);return i<<=8,i|=s,i}identToString(e){const t=[];for(;e;)t.push(String.fromCharCode(255&e)),e>>=8;return t.reverse().join("")}setPrintHandler(e){this._printHandler=e}clearPrintHandler(){this._printHandler=this._printHandlerFb}registerEscHandler(e,t){const i=this._identifier(e,[48,126]);void 0===this._escHandlers[i]&&(this._escHandlers[i]=[]);const s=this._escHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearEscHandler(e){this._escHandlers[this._identifier(e,[48,126])]&&delete this._escHandlers[this._identifier(e,[48,126])]}setEscHandlerFallback(e){this._escHandlerFb=e}setExecuteHandler(e,t){this._executeHandlers[e.charCodeAt(0)]=t}clearExecuteHandler(e){this._executeHandlers[e.charCodeAt(0)]&&delete this._executeHandlers[e.charCodeAt(0)]}setExecuteHandlerFallback(e){this._executeHandlerFb=e}registerCsiHandler(e,t){const i=this._identifier(e);void 0===this._csiHandlers[i]&&(this._csiHandlers[i]=[]);const s=this._csiHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearCsiHandler(e){this._csiHandlers[this._identifier(e)]&&delete this._csiHandlers[this._identifier(e)]}setCsiHandlerFallback(e){this._csiHandlerFb=e}registerDcsHandler(e,t){return this._dcsParser.registerHandler(this._identifier(e),t)}clearDcsHandler(e){this._dcsParser.clearHandler(this._identifier(e))}setDcsHandlerFallback(e){this._dcsParser.setHandlerFallback(e)}registerOscHandler(e,t){return this._oscParser.registerHandler(e,t)}clearOscHandler(e){this._oscParser.clearHandler(e)}setOscHandlerFallback(e){this._oscParser.setHandlerFallback(e)}setErrorHandler(e){this._errorHandler=e}clearErrorHandler(){this._errorHandler=this._errorHandlerFb}reset(){this.currentState=this.initialState,this._oscParser.reset(),this._dcsParser.reset(),this._params.reset(),this._params.addParam(0),this._collect=0,this.precedingJoinState=0,0!==this._parseStack.state&&(this._parseStack.state=2,this._parseStack.handlers=[])}_preserveStack(e,t,i,s,r){this._parseStack.state=e,this._parseStack.handlers=t,this._parseStack.handlerPos=i,this._parseStack.transition=s,this._parseStack.chunkPos=r}parse(e,t,i){let s,r=0,n=0,o=0;if(this._parseStack.state)if(2===this._parseStack.state)this._parseStack.state=0,o=this._parseStack.chunkPos+1;else{if(void 0===i||1===this._parseStack.state)throw this._parseStack.state=1,new Error("improper continuation due to previous async handler, giving up parsing");const t=this._parseStack.handlers;let n=this._parseStack.handlerPos-1;switch(this._parseStack.state){case 3:if(!1===i&&n>-1)for(;n>=0&&(s=t[n](this._params),!0!==s);n--)if(s instanceof Promise)return this._parseStack.handlerPos=n,s;this._parseStack.handlers=[];break;case 4:if(!1===i&&n>-1)for(;n>=0&&(s=t[n](),!0!==s);n--)if(s instanceof Promise)return this._parseStack.handlerPos=n,s;this._parseStack.handlers=[];break;case 6:if(r=e[this._parseStack.chunkPos],s=this._dcsParser.unhook(24!==r&&26!==r,i),s)return s;27===r&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0;break;case 5:if(r=e[this._parseStack.chunkPos],s=this._oscParser.end(24!==r&&26!==r,i),s)return s;27===r&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0}this._parseStack.state=0,o=this._parseStack.chunkPos+1,this.precedingJoinState=0,this.currentState=15&this._parseStack.transition}for(let i=o;i>4){case 2:for(let s=i+1;;++s){if(s>=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=0&&(s=o[a](this._params),!0!==s);a--)if(s instanceof Promise)return this._preserveStack(3,o,a,n,i),s;a<0&&this._csiHandlerFb(this._collect<<8|r,this._params),this.precedingJoinState=0;break;case 8:do{switch(r){case 59:this._params.addParam(0);break;case 58:this._params.addSubParam(-1);break;default:this._params.addDigit(r-48)}}while(++i47&&r<60);i--;break;case 9:this._collect<<=8,this._collect|=r;break;case 10:const h=this._escHandlers[this._collect<<8|r];let c=h?h.length-1:-1;for(;c>=0&&(s=h[c](),!0!==s);c--)if(s instanceof Promise)return this._preserveStack(4,h,c,n,i),s;c<0&&this._escHandlerFb(this._collect<<8|r),this.precedingJoinState=0;break;case 11:this._params.reset(),this._params.addParam(0),this._collect=0;break;case 12:this._dcsParser.hook(this._collect<<8|r,this._params);break;case 13:for(let s=i+1;;++s)if(s>=t||24===(r=e[s])||26===r||27===r||r>127&&r=t||(r=e[s])<32||r>127&&r{Object.defineProperty(t,"__esModule",{value:!0}),t.OscHandler=t.OscParser=void 0;const s=i(1263),r=i(726),n=[];t.OscParser=class{constructor(){this._state=0,this._active=n,this._id=-1,this._handlers=Object.create(null),this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(e,t){void 0===this._handlers[e]&&(this._handlers[e]=[]);const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=n}reset(){if(2===this._state)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=n,this._id=-1,this._state=0}_start(){if(this._active=this._handlers[this._id]||n,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].start();else this._handlerFb(this._id,"START")}_put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._id,"PUT",(0,r.utf32ToString)(e,t,i))}start(){this.reset(),this._state=1}put(e,t,i){if(3!==this._state){if(1===this._state)for(;t0&&this._put(e,t,i)}}end(e,t=!0){if(0!==this._state){if(3!==this._state)if(1===this._state&&this._start(),this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].end(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].end(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._id,"END",e);this._active=n,this._id=-1,this._state=0}}},t.OscHandler=class{constructor(e){this._handler=e,this._data="",this._hitLimit=!1}start(){this._data="",this._hitLimit=!1}put(e,t,i){this._hitLimit||(this._data+=(0,r.utf32ToString)(e,t,i),this._data.length>s.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))}end(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data),t instanceof Promise))return t.then((e=>(this._data="",this._hitLimit=!1,e)));return this._data="",this._hitLimit=!1,t}}},7262:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Params=void 0;const i=2147483647;class s{static fromArray(e){const t=new s;if(!e.length)return t;for(let i=Array.isArray(e[0])?1:0;i256)throw new Error("maxSubParamsLength must not be greater than 256");this.params=new Int32Array(e),this.length=0,this._subParams=new Int32Array(t),this._subParamsLength=0,this._subParamsIdx=new Uint16Array(e),this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}clone(){const e=new s(this.maxLength,this.maxSubParamsLength);return e.params.set(this.params),e.length=this.length,e._subParams.set(this._subParams),e._subParamsLength=this._subParamsLength,e._subParamsIdx.set(this._subParamsIdx),e._rejectDigits=this._rejectDigits,e._rejectSubDigits=this._rejectSubDigits,e._digitIsSub=this._digitIsSub,e}toArray(){const e=[];for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&e.push(Array.prototype.slice.call(this._subParams,i,s))}return e}reset(){this.length=0,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}addParam(e){if(this._digitIsSub=!1,this.length>=this.maxLength)this._rejectDigits=!0;else{if(e<-1)throw new Error("values lesser than -1 are not allowed");this._subParamsIdx[this.length]=this._subParamsLength<<8|this._subParamsLength,this.params[this.length++]=e>i?i:e}}addSubParam(e){if(this._digitIsSub=!0,this.length)if(this._rejectDigits||this._subParamsLength>=this.maxSubParamsLength)this._rejectSubDigits=!0;else{if(e<-1)throw new Error("values lesser than -1 are not allowed");this._subParams[this._subParamsLength++]=e>i?i:e,this._subParamsIdx[this.length-1]++}}hasSubParams(e){return(255&this._subParamsIdx[e])-(this._subParamsIdx[e]>>8)>0}getSubParams(e){const t=this._subParamsIdx[e]>>8,i=255&this._subParamsIdx[e];return i-t>0?this._subParams.subarray(t,i):null}getSubParamsAll(){const e={};for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&(e[t]=this._subParams.slice(i,s))}return e}addDigit(e){let t;if(this._rejectDigits||!(t=this._digitIsSub?this._subParamsLength:this.length)||this._digitIsSub&&this._rejectSubDigits)return;const s=this._digitIsSub?this._subParams:this.params,r=s[t-1];s[t-1]=~r?Math.min(10*r+e,i):e}}t.Params=s},3027:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.AddonManager=void 0,t.AddonManager=class{constructor(){this._addons=[]}dispose(){for(let e=this._addons.length-1;e>=0;e--)this._addons[e].instance.dispose()}loadAddon(e,t){const i={instance:t,dispose:t.dispose,isDisposed:!1};this._addons.push(i),t.dispose=()=>this._wrappedAddonDispose(i),t.activate(e)}_wrappedAddonDispose(e){if(e.isDisposed)return;let t=-1;for(let i=0;i{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferApiView=void 0;const s=i(793),r=i(3055);t.BufferApiView=class{constructor(e,t){this._buffer=e,this.type=t}init(e){return this._buffer=e,this}get cursorY(){return this._buffer.y}get cursorX(){return this._buffer.x}get viewportY(){return this._buffer.ydisp}get baseY(){return this._buffer.ybase}get length(){return this._buffer.lines.length}getLine(e){const t=this._buffer.lines.get(e);if(t)return new s.BufferLineApiView(t)}getNullCell(){return new r.CellData}}},793:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLineApiView=void 0;const s=i(3055);t.BufferLineApiView=class{constructor(e){this._line=e}get isWrapped(){return this._line.isWrapped}get length(){return this._line.length}getCell(e,t){if(!(e<0||e>=this._line.length))return t?(this._line.loadCell(e,t),t):this._line.loadCell(e,new s.CellData)}translateToString(e,t,i){return this._line.translateToString(e,t,i)}}},5101:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferNamespaceApi=void 0;const s=i(3235),r=i(7150),n=i(802);class o extends r.Disposable{constructor(e){super(),this._core=e,this._onBufferChange=this._register(new n.Emitter),this.onBufferChange=this._onBufferChange.event,this._normal=new s.BufferApiView(this._core.buffers.normal,"normal"),this._alternate=new s.BufferApiView(this._core.buffers.alt,"alternate"),this._core.buffers.onBufferActivate((()=>this._onBufferChange.fire(this.active)))}get active(){if(this._core.buffers.active===this._core.buffers.normal)return this.normal;if(this._core.buffers.active===this._core.buffers.alt)return this.alternate;throw new Error("Active buffer is neither normal nor alternate")}get normal(){return this._normal.init(this._core.buffers.normal)}get alternate(){return this._alternate.init(this._core.buffers.alt)}}t.BufferNamespaceApi=o},6097:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ParserApi=void 0,t.ParserApi=class{constructor(e){this._core=e}registerCsiHandler(e,t){return this._core.registerCsiHandler(e,(e=>t(e.toArray())))}addCsiHandler(e,t){return this.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._core.registerDcsHandler(e,((e,i)=>t(e,i.toArray())))}addDcsHandler(e,t){return this.registerDcsHandler(e,t)}registerEscHandler(e,t){return this._core.registerEscHandler(e,t)}addEscHandler(e,t){return this.registerEscHandler(e,t)}registerOscHandler(e,t){return this._core.registerOscHandler(e,t)}addOscHandler(e,t){return this.registerOscHandler(e,t)}}},4335:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeApi=void 0,t.UnicodeApi=class{constructor(e){this._core=e}register(e){this._core.unicodeService.register(e)}get versions(){return this._core.unicodeService.versions}get activeVersion(){return this._core.unicodeService.activeVersion}set activeVersion(e){this._core.unicodeService.activeVersion=e}}},9640:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferService=t.MINIMUM_ROWS=t.MINIMUM_COLS=void 0;const n=i(7150),o=i(4097),a=i(6501),l=i(802);t.MINIMUM_COLS=2,t.MINIMUM_ROWS=1;let h=class extends n.Disposable{get buffer(){return this.buffers.active}constructor(e){super(),this.isUserScrolling=!1,this._onResize=this._register(new l.Emitter),this.onResize=this._onResize.event,this._onScroll=this._register(new l.Emitter),this.onScroll=this._onScroll.event,this.cols=Math.max(e.rawOptions.cols||0,t.MINIMUM_COLS),this.rows=Math.max(e.rawOptions.rows||0,t.MINIMUM_ROWS),this.buffers=this._register(new o.BufferSet(e,this)),this._register(this.buffers.onBufferActivate((e=>{this._onScroll.fire(e.activeBuffer.ydisp)})))}resize(e,t){const i=this.cols!==e,s=this.rows!==t;this.cols=e,this.rows=t,this.buffers.resize(e,t),this._onResize.fire({cols:e,rows:t,colsChanged:i,rowsChanged:s})}reset(){this.buffers.reset(),this.isUserScrolling=!1}scroll(e,t=!1){const i=this.buffer;let s;s=this._cachedBlankLine,s&&s.length===this.cols&&s.getFg(0)===e.fg&&s.getBg(0)===e.bg||(s=i.getBlankLine(e,t),this._cachedBlankLine=s),s.isWrapped=t;const r=i.ybase+i.scrollTop,n=i.ybase+i.scrollBottom;if(0===i.scrollTop){const e=i.lines.isFull;n===i.lines.length-1?e?i.lines.recycle().copyFrom(s):i.lines.push(s.clone()):i.lines.splice(n+1,0,s.clone()),e?this.isUserScrolling&&(i.ydisp=Math.max(i.ydisp-1,0)):(i.ybase++,this.isUserScrolling||i.ydisp++)}else{const e=n-r+1;i.lines.shiftElements(r+1,e-1,-1),i.lines.set(n,s.clone())}this.isUserScrolling||(i.ydisp=i.ybase),this._onScroll.fire(i.ydisp)}scrollLines(e,t){const i=this.buffer;if(e<0){if(0===i.ydisp)return;this.isUserScrolling=!0}else e+i.ydisp>=i.ybase&&(this.isUserScrolling=!1);const s=i.ydisp;i.ydisp=Math.max(Math.min(i.ydisp+e,i.ybase),0),s!==i.ydisp&&(t||this._onScroll.fire(i.ydisp))}};t.BufferService=h,t.BufferService=h=s([r(0,a.IOptionsService)],h)},5746:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CharsetService=void 0,t.CharsetService=class{constructor(){this.glevel=0,this._charsets=[]}reset(){this.charset=void 0,this._charsets=[],this.glevel=0}setgLevel(e){this.glevel=e,this.charset=this._charsets[e]}setgCharset(e,t){this._charsets[e]=t,this.glevel===e&&(this.charset=t)}}},7792:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreMouseService=void 0;const n=i(6501),o=i(7150),a=i(802),l={NONE:{events:0,restrict:()=>!1},X10:{events:1,restrict:e=>4!==e.button&&1===e.action&&(e.ctrl=!1,e.alt=!1,e.shift=!1,!0)},VT200:{events:19,restrict:e=>32!==e.action},DRAG:{events:23,restrict:e=>32!==e.action||3!==e.button},ANY:{events:31,restrict:e=>!0}};function h(e,t){let i=(e.ctrl?16:0)|(e.shift?4:0)|(e.alt?8:0);return 4===e.button?(i|=64,i|=e.action):(i|=3&e.button,4&e.button&&(i|=64),8&e.button&&(i|=128),32===e.action?i|=32:0!==e.action||t||(i|=3)),i}const c=String.fromCharCode,d={DEFAULT:e=>{const t=[h(e,!1)+32,e.col+32,e.row+32];return t[0]>255||t[1]>255||t[2]>255?"":`${c(t[0])}${c(t[1])}${c(t[2])}`},SGR:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${h(e,!0)};${e.col};${e.row}${t}`},SGR_PIXELS:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${h(e,!0)};${e.x};${e.y}${t}`}};let u=class extends o.Disposable{constructor(e,t,i){super(),this._bufferService=e,this._coreService=t,this._optionsService=i,this._protocols={},this._encodings={},this._activeProtocol="",this._activeEncoding="",this._lastEvent=null,this._wheelPartialScroll=0,this._onProtocolChange=this._register(new a.Emitter),this.onProtocolChange=this._onProtocolChange.event;for(const e of Object.keys(l))this.addProtocol(e,l[e]);for(const e of Object.keys(d))this.addEncoding(e,d[e]);this.reset()}addProtocol(e,t){this._protocols[e]=t}addEncoding(e,t){this._encodings[e]=t}get activeProtocol(){return this._activeProtocol}get areMouseEventsActive(){return 0!==this._protocols[this._activeProtocol].events}set activeProtocol(e){if(!this._protocols[e])throw new Error(`unknown protocol "${e}"`);this._activeProtocol=e,this._onProtocolChange.fire(this._protocols[e].events)}get activeEncoding(){return this._activeEncoding}set activeEncoding(e){if(!this._encodings[e])throw new Error(`unknown encoding "${e}"`);this._activeEncoding=e}reset(){this.activeProtocol="NONE",this.activeEncoding="DEFAULT",this._lastEvent=null,this._wheelPartialScroll=0}consumeWheelEvent(e,t,i){if(0===e.deltaY||e.shiftKey)return 0;if(void 0===t||void 0===i)return 0;const s=t/i;let r=this._applyScrollModifier(e.deltaY,e);return e.deltaMode===WheelEvent.DOM_DELTA_PIXEL?(r/=s+0,Math.abs(e.deltaY)<50&&(r*=.3),this._wheelPartialScroll+=r,r=Math.floor(Math.abs(this._wheelPartialScroll))*(this._wheelPartialScroll>0?1:-1),this._wheelPartialScroll%=1):e.deltaMode===WheelEvent.DOM_DELTA_PAGE&&(r*=this._bufferService.rows),r}_applyScrollModifier(e,t){return t.altKey||t.ctrlKey||t.shiftKey?e*this._optionsService.rawOptions.fastScrollSensitivity*this._optionsService.rawOptions.scrollSensitivity:e*this._optionsService.rawOptions.scrollSensitivity}triggerMouseEvent(e){if(e.col<0||e.col>=this._bufferService.cols||e.row<0||e.row>=this._bufferService.rows)return!1;if(4===e.button&&32===e.action)return!1;if(3===e.button&&32!==e.action)return!1;if(4!==e.button&&(2===e.action||3===e.action))return!1;if(e.col++,e.row++,32===e.action&&this._lastEvent&&this._equalEvents(this._lastEvent,e,"SGR_PIXELS"===this._activeEncoding))return!1;if(!this._protocols[this._activeProtocol].restrict(e))return!1;const t=this._encodings[this._activeEncoding](e);return t&&("DEFAULT"===this._activeEncoding?this._coreService.triggerBinaryEvent(t):this._coreService.triggerDataEvent(t,!0)),this._lastEvent=e,!0}explainEvents(e){return{down:!!(1&e),up:!!(2&e),drag:!!(4&e),move:!!(8&e),wheel:!!(16&e)}}_equalEvents(e,t,i){if(i){if(e.x!==t.x)return!1;if(e.y!==t.y)return!1}else{if(e.col!==t.col)return!1;if(e.row!==t.row)return!1}return e.button===t.button&&e.action===t.action&&e.ctrl===t.ctrl&&e.alt===t.alt&&e.shift===t.shift}};t.CoreMouseService=u,t.CoreMouseService=u=s([r(0,n.IBufferService),r(1,n.ICoreService),r(2,n.IOptionsService)],u)},4071:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreService=void 0;const n=i(7453),o=i(7150),a=i(6501),l=i(802),h=Object.freeze({insertMode:!1}),c=Object.freeze({applicationCursorKeys:!1,applicationKeypad:!1,bracketedPasteMode:!1,cursorBlink:void 0,cursorStyle:void 0,origin:!1,reverseWraparound:!1,sendFocus:!1,synchronizedOutput:!1,wraparound:!0});let d=class extends o.Disposable{constructor(e,t,i){super(),this._bufferService=e,this._logService=t,this._optionsService=i,this.isCursorInitialized=!1,this.isCursorHidden=!1,this._onData=this._register(new l.Emitter),this.onData=this._onData.event,this._onUserInput=this._register(new l.Emitter),this.onUserInput=this._onUserInput.event,this._onBinary=this._register(new l.Emitter),this.onBinary=this._onBinary.event,this._onRequestScrollToBottom=this._register(new l.Emitter),this.onRequestScrollToBottom=this._onRequestScrollToBottom.event,this.modes=(0,n.clone)(h),this.decPrivateModes=(0,n.clone)(c)}reset(){this.modes=(0,n.clone)(h),this.decPrivateModes=(0,n.clone)(c)}triggerDataEvent(e,t=!1){if(this._optionsService.rawOptions.disableStdin)return;const i=this._bufferService.buffer;t&&this._optionsService.rawOptions.scrollOnUserInput&&i.ybase!==i.ydisp&&this._onRequestScrollToBottom.fire(),t&&this._onUserInput.fire(),this._logService.debug(`sending data "${e}"`),this._logService.trace("sending data (codes)",(()=>e.split("").map((e=>e.charCodeAt(0))))),this._onData.fire(e)}triggerBinaryEvent(e){this._optionsService.rawOptions.disableStdin||(this._logService.debug(`sending binary "${e}"`),this._logService.trace("sending binary (codes)",(()=>e.split("").map((e=>e.charCodeAt(0))))),this._onBinary.fire(e))}};t.CoreService=d,t.CoreService=d=s([r(0,a.IBufferService),r(1,a.ILogService),r(2,a.IOptionsService)],d)},4720:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DecorationService=void 0;const s=i(4103),r=i(7150),n=i(3087),o=i(802);let a=0,l=0;class h extends r.Disposable{get decorations(){return this._decorations.values()}constructor(){super(),this._decorations=new n.SortedList((e=>e?.marker.line)),this._onDecorationRegistered=this._register(new o.Emitter),this.onDecorationRegistered=this._onDecorationRegistered.event,this._onDecorationRemoved=this._register(new o.Emitter),this.onDecorationRemoved=this._onDecorationRemoved.event,this._register((0,r.toDisposable)((()=>this.reset())))}registerDecoration(e){if(e.marker.isDisposed)return;const t=new c(e);if(t){const e=t.marker.onDispose((()=>t.dispose())),i=t.onDispose((()=>{i.dispose(),t&&(this._decorations.delete(t)&&this._onDecorationRemoved.fire(t),e.dispose())}));this._decorations.insert(t),this._onDecorationRegistered.fire(t)}return t}reset(){for(const e of this._decorations.values())e.dispose();this._decorations.clear()}*getDecorationsAtCell(e,t,i){let s=0,r=0;for(const n of this._decorations.getKeyIterator(t))s=n.options.x??0,r=s+(n.options.width??1),e>=s&&e{a=t.options.x??0,l=a+(t.options.width??1),e>=a&&e{Object.defineProperty(t,"__esModule",{value:!0}),t.InstantiationService=t.ServiceCollection=void 0;const s=i(6501),r=i(6201);class n{constructor(...e){this._entries=new Map;for(const[t,i]of e)this.set(t,i)}set(e,t){const i=this._entries.get(e);return this._entries.set(e,t),i}forEach(e){for(const[t,i]of this._entries.entries())e(t,i)}has(e){return this._entries.has(e)}get(e){return this._entries.get(e)}}t.ServiceCollection=n,t.InstantiationService=class{constructor(){this._services=new n,this._services.set(s.IInstantiationService,this)}setService(e,t){this._services.set(e,t)}getService(e){return this._services.get(e)}createInstance(e,...t){const i=(0,r.getServiceDependencies)(e).sort(((e,t)=>e.index-t.index)),s=[];for(const t of i){const i=this._services.get(t.id);if(!i)throw new Error(`[createInstance] ${e.name} depends on UNKNOWN service ${t.id._id}.`);s.push(i)}const n=i.length>0?i[0].index:t.length;if(t.length!==n)throw new Error(`[createInstance] First service dependency of ${e.name} at position ${n+1} conflicts with ${t.length} static arguments`);return new e(...[...t,...s])}}},7276:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.LogService=void 0,t.setTraceLogger=function(e){l=e},t.traceCall=function(e,t,i){if("function"!=typeof i.value)throw new Error("not supported");const s=i.value;i.value=function(...e){if(l.logLevel!==o.LogLevelEnum.TRACE)return s.apply(this,e);l.trace(`GlyphRenderer#${s.name}(${e.map((e=>JSON.stringify(e))).join(", ")})`);const t=s.apply(this,e);return l.trace(`GlyphRenderer#${s.name} return`,t),t}};const n=i(7150),o=i(6501),a={trace:o.LogLevelEnum.TRACE,debug:o.LogLevelEnum.DEBUG,info:o.LogLevelEnum.INFO,warn:o.LogLevelEnum.WARN,error:o.LogLevelEnum.ERROR,off:o.LogLevelEnum.OFF};let l,h=class extends n.Disposable{get logLevel(){return this._logLevel}constructor(e){super(),this._optionsService=e,this._logLevel=o.LogLevelEnum.OFF,this._updateLogLevel(),this._register(this._optionsService.onSpecificOptionChange("logLevel",(()=>this._updateLogLevel()))),l=this}_updateLogLevel(){this._logLevel=a[this._optionsService.rawOptions.logLevel]}_evalLazyOptionalParams(e){for(let t=0;t{Object.defineProperty(t,"__esModule",{value:!0}),t.OptionsService=t.DEFAULT_OPTIONS=void 0;const s=i(7150),r=i(701),n=i(802);t.DEFAULT_OPTIONS={cols:80,rows:24,cursorBlink:!1,cursorStyle:"block",cursorWidth:1,cursorInactiveStyle:"outline",customGlyphs:!0,drawBoldTextInBrightColors:!0,documentOverride:null,fastScrollModifier:"alt",fastScrollSensitivity:5,fontFamily:"monospace",fontSize:15,fontWeight:"normal",fontWeightBold:"bold",ignoreBracketedPasteMode:!1,lineHeight:1,letterSpacing:0,linkHandler:null,logLevel:"info",logger:null,scrollback:1e3,scrollOnEraseInDisplay:!1,scrollOnUserInput:!0,scrollSensitivity:1,screenReaderMode:!1,smoothScrollDuration:0,macOptionIsMeta:!1,macOptionClickForcesSelection:!1,minimumContrastRatio:1,disableStdin:!1,allowProposedApi:!1,allowTransparency:!1,tabStopWidth:8,theme:{},reflowCursorLine:!1,rescaleOverlappingGlyphs:!1,rightClickSelectsWord:r.isMac,windowOptions:{},windowsMode:!1,windowsPty:{},wordSeparator:" ()[]{}',\"`",altClickMovesCursor:!0,convertEol:!1,termName:"xterm",cancelEvents:!1,overviewRuler:{}};const o=["normal","bold","100","200","300","400","500","600","700","800","900"];class a extends s.Disposable{constructor(e){super(),this._onOptionChange=this._register(new n.Emitter),this.onOptionChange=this._onOptionChange.event;const i={...t.DEFAULT_OPTIONS};for(const t in e)if(t in i)try{const s=e[t];i[t]=this._sanitizeAndValidateOption(t,s)}catch(e){console.error(e)}this.rawOptions=i,this.options={...i},this._setupOptions(),this._register((0,s.toDisposable)((()=>{this.rawOptions.linkHandler=null,this.rawOptions.documentOverride=null})))}onSpecificOptionChange(e,t){return this.onOptionChange((i=>{i===e&&t(this.rawOptions[e])}))}onMultipleOptionChange(e,t){return this.onOptionChange((i=>{-1!==e.indexOf(i)&&t()}))}_setupOptions(){const e=e=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);return this.rawOptions[e]},i=(e,i)=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);i=this._sanitizeAndValidateOption(e,i),this.rawOptions[e]!==i&&(this.rawOptions[e]=i,this._onOptionChange.fire(e))};for(const t in this.rawOptions){const s={get:e.bind(this,t),set:i.bind(this,t)};Object.defineProperty(this.options,t,s)}}_sanitizeAndValidateOption(e,i){switch(e){case"cursorStyle":if(i||(i=t.DEFAULT_OPTIONS[e]),!function(e){return"block"===e||"underline"===e||"bar"===e}(i))throw new Error(`"${i}" is not a valid value for ${e}`);break;case"wordSeparator":i||(i=t.DEFAULT_OPTIONS[e]);break;case"fontWeight":case"fontWeightBold":if("number"==typeof i&&1<=i&&i<=1e3)break;i=o.includes(i)?i:t.DEFAULT_OPTIONS[e];break;case"cursorWidth":i=Math.floor(i);case"lineHeight":case"tabStopWidth":if(i<1)throw new Error(`${e} cannot be less than 1, value: ${i}`);break;case"minimumContrastRatio":i=Math.max(1,Math.min(21,Math.round(10*i)/10));break;case"scrollback":if((i=Math.min(i,4294967295))<0)throw new Error(`${e} cannot be less than 0, value: ${i}`);break;case"fastScrollSensitivity":case"scrollSensitivity":if(i<=0)throw new Error(`${e} cannot be less than or equal to 0, value: ${i}`);break;case"rows":case"cols":if(!i&&0!==i)throw new Error(`${e} must be numeric, value: ${i}`);break;case"windowsPty":i=i??{}}return i}}t.OptionsService=a},8811:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkService=void 0;const n=i(6501);let o=class{constructor(e){this._bufferService=e,this._nextId=1,this._entriesWithId=new Map,this._dataByLinkId=new Map}registerLink(e){const t=this._bufferService.buffer;if(void 0===e.id){const i=t.addMarker(t.ybase+t.y),s={data:e,id:this._nextId++,lines:[i]};return i.onDispose((()=>this._removeMarkerFromLink(s,i))),this._dataByLinkId.set(s.id,s),s.id}const i=e,s=this._getEntryIdKey(i),r=this._entriesWithId.get(s);if(r)return this.addLineToLink(r.id,t.ybase+t.y),r.id;const n=t.addMarker(t.ybase+t.y),o={id:this._nextId++,key:this._getEntryIdKey(i),data:i,lines:[n]};return n.onDispose((()=>this._removeMarkerFromLink(o,n))),this._entriesWithId.set(o.key,o),this._dataByLinkId.set(o.id,o),o.id}addLineToLink(e,t){const i=this._dataByLinkId.get(e);if(i&&i.lines.every((e=>e.line!==t))){const e=this._bufferService.buffer.addMarker(t);i.lines.push(e),e.onDispose((()=>this._removeMarkerFromLink(i,e)))}}getLinkData(e){return this._dataByLinkId.get(e)?.data}_getEntryIdKey(e){return`${e.id};;${e.uri}`}_removeMarkerFromLink(e,t){const i=e.lines.indexOf(t);-1!==i&&(e.lines.splice(i,1),0===e.lines.length&&(void 0!==e.data.id&&this._entriesWithId.delete(e.key),this._dataByLinkId.delete(e.id)))}};t.OscLinkService=o,t.OscLinkService=o=s([r(0,n.IBufferService)],o)},6201:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.serviceRegistry=void 0,t.getServiceDependencies=function(e){return e[s]||[]},t.createDecorator=function(e){if(t.serviceRegistry.has(e))return t.serviceRegistry.get(e);const r=function(e,t,n){if(3!==arguments.length)throw new Error("@IServiceName-decorator can only be used to decorate a parameter");!function(e,t,r){t[i]===t?t[s].push({id:e,index:r}):(t[s]=[{id:e,index:r}],t[i]=t)}(r,e,n)};return r._id=e,t.serviceRegistry.set(e,r),r};const i="di$target",s="di$dependencies";t.serviceRegistry=new Map},6501:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.IDecorationService=t.IUnicodeService=t.IOscLinkService=t.IOptionsService=t.ILogService=t.LogLevelEnum=t.IInstantiationService=t.ICharsetService=t.ICoreService=t.ICoreMouseService=t.IBufferService=void 0;const s=i(6201);var r;t.IBufferService=(0,s.createDecorator)("BufferService"),t.ICoreMouseService=(0,s.createDecorator)("CoreMouseService"),t.ICoreService=(0,s.createDecorator)("CoreService"),t.ICharsetService=(0,s.createDecorator)("CharsetService"),t.IInstantiationService=(0,s.createDecorator)("InstantiationService"),function(e){e[e.TRACE=0]="TRACE",e[e.DEBUG=1]="DEBUG",e[e.INFO=2]="INFO",e[e.WARN=3]="WARN",e[e.ERROR=4]="ERROR",e[e.OFF=5]="OFF"}(r||(t.LogLevelEnum=r={})),t.ILogService=(0,s.createDecorator)("LogService"),t.IOptionsService=(0,s.createDecorator)("OptionsService"),t.IOscLinkService=(0,s.createDecorator)("OscLinkService"),t.IUnicodeService=(0,s.createDecorator)("UnicodeService"),t.IDecorationService=(0,s.createDecorator)("DecorationService")},6415:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeService=void 0;const s=i(7428),r=i(802);class n{static extractShouldJoin(e){return!!(1&e)}static extractWidth(e){return e>>1&3}static extractCharKind(e){return e>>3}static createPropertyValue(e,t,i=!1){return(16777215&e)<<3|(3&t)<<1|(i?1:0)}constructor(){this._providers=Object.create(null),this._active="",this._onChange=new r.Emitter,this.onChange=this._onChange.event;const e=new s.UnicodeV6;this.register(e),this._active=e.version,this._activeProvider=e}dispose(){this._onChange.dispose()}get versions(){return Object.keys(this._providers)}get activeVersion(){return this._active}set activeVersion(e){if(!this._providers[e])throw new Error(`unknown Unicode version "${e}"`);this._active=e,this._activeProvider=this._providers[e],this._onChange.fire(e)}register(e){this._providers[e.version]=e}wcwidth(e){return this._activeProvider.wcwidth(e)}getStringCellWidth(e){let t=0,i=0;const s=e.length;for(let r=0;r=s)return t+this.wcwidth(o);const i=e.charCodeAt(r);56320<=i&&i<=57343?o=1024*(o-55296)+i-56320+65536:t+=this.wcwidth(i)}const a=this.charProperties(o,i);let l=n.extractWidth(a);n.extractShouldJoin(a)&&(l-=n.extractWidth(i)),t+=l,i=a}return t}charProperties(e,t){return this._activeProvider.charProperties(e,t)}}t.UnicodeService=n},4333:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.isAndroid=t.isElectron=t.isWebkitWebView=t.isSafari=t.isChrome=t.isWebKit=t.isFirefox=t.onDidChangeFullscreen=t.onDidChangeZoomLevel=void 0,t.addMatchMediaChangeListener=o,t.setZoomLevel=function(e,t){n.INSTANCE.setZoomLevel(e,t)},t.getZoomLevel=function(e){return n.INSTANCE.getZoomLevel(e)},t.getZoomFactor=function(e){return n.INSTANCE.getZoomFactor(e)},t.setZoomFactor=function(e,t){n.INSTANCE.setZoomFactor(e,t)},t.setFullscreen=function(e,t){n.INSTANCE.setFullscreen(e,t)},t.isFullscreen=function(e){return n.INSTANCE.isFullscreen(e)},t.isStandalone=function(){return l},t.isWCOEnabled=function(){return navigator?.windowControlsOverlay?.visible},t.getWCOBoundingRect=function(){return navigator?.windowControlsOverlay?.getTitlebarAreaRect()};const s=i(4693),r=i(802);class n{constructor(){this.mapWindowIdToZoomLevel=new Map,this._onDidChangeZoomLevel=new r.Emitter,this.onDidChangeZoomLevel=this._onDidChangeZoomLevel.event,this.mapWindowIdToZoomFactor=new Map,this._onDidChangeFullscreen=new r.Emitter,this.onDidChangeFullscreen=this._onDidChangeFullscreen.event,this.mapWindowIdToFullScreen=new Map}static{this.INSTANCE=new n}getZoomLevel(e){return this.mapWindowIdToZoomLevel.get(this.getWindowId(e))??0}setZoomLevel(e,t){if(this.getZoomLevel(t)===e)return;const i=this.getWindowId(t);this.mapWindowIdToZoomLevel.set(i,e),this._onDidChangeZoomLevel.fire(i)}getZoomFactor(e){return this.mapWindowIdToZoomFactor.get(this.getWindowId(e))??1}setZoomFactor(e,t){this.mapWindowIdToZoomFactor.set(this.getWindowId(t),e)}setFullscreen(e,t){if(this.isFullscreen(t)===e)return;const i=this.getWindowId(t);this.mapWindowIdToFullScreen.set(i,e),this._onDidChangeFullscreen.fire(i)}isFullscreen(e){return!!this.mapWindowIdToFullScreen.get(this.getWindowId(e))}getWindowId(e){return e.vscodeWindowId}}function o(e,t,i){"string"==typeof t&&(t=e.matchMedia(t)),t.addEventListener("change",i)}t.onDidChangeZoomLevel=n.INSTANCE.onDidChangeZoomLevel,t.onDidChangeFullscreen=n.INSTANCE.onDidChangeFullscreen;const a="object"==typeof navigator?navigator.userAgent:"";t.isFirefox=a.indexOf("Firefox")>=0,t.isWebKit=a.indexOf("AppleWebKit")>=0,t.isChrome=a.indexOf("Chrome")>=0,t.isSafari=!t.isChrome&&a.indexOf("Safari")>=0,t.isWebkitWebView=!t.isChrome&&!t.isSafari&&t.isWebKit,t.isElectron=a.indexOf("Electron/")>=0,t.isAndroid=a.indexOf("Android")>=0;let l=!1;if("function"==typeof s.mainWindow.matchMedia){const e=s.mainWindow.matchMedia("(display-mode: standalone) or (display-mode: window-controls-overlay)"),t=s.mainWindow.matchMedia("(display-mode: fullscreen)");l=e.matches,o(s.mainWindow,e,(({matches:e})=>{l&&t.matches||(l=e)}))}},7745:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.BrowserFeatures=t.KeyboardSupport=void 0;const o=n(i(4333)),a=i(4693),l=n(i(8163));var h;!function(e){e[e.Always=0]="Always",e[e.FullScreen=1]="FullScreen",e[e.None=2]="None"}(h||(t.KeyboardSupport=h={}));const c="object"==typeof navigator?navigator:{};t.BrowserFeatures={clipboard:{writeText:l.isNative||document.queryCommandSupported&&document.queryCommandSupported("copy")||!!(c&&c.clipboard&&c.clipboard.writeText),readText:l.isNative||!!(c&&c.clipboard&&c.clipboard.readText)},keyboard:l.isNative||o.isStandalone()?h.Always:c.keyboard||o.isSafari?h.FullScreen:h.None,touch:"ontouchstart"in a.mainWindow||c.maxTouchPoints>0,pointerEvents:a.mainWindow.PointerEvent&&("ontouchstart"in a.mainWindow||navigator.maxTouchPoints>0)}},7093:function(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),n=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),o=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&r(t,e,i);return n(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.SafeTriangle=t.DragAndDropObserver=t.ModifierKeyEmitter=t.DetectedFullscreenMode=t.Namespace=t.EventHelper=t.EventType=t.sharedMutationObserver=t.Dimension=t.WindowIntervalTimer=t.scheduleAtNextAnimationFrame=t.runAtThisOrScheduleAtNextAnimationFrame=t.WindowIdleValue=t.addStandardDisposableGenericMouseUpListener=t.addStandardDisposableGenericMouseDownListener=t.addStandardDisposableListener=t.onDidUnregisterWindow=t.onWillUnregisterWindow=t.onDidRegisterWindow=t.hasWindow=t.getWindowById=t.getWindowId=t.getWindowsCount=t.getWindows=t.getDocument=t.getWindow=t.registerWindow=void 0,t.clearNode=function(e){for(;e.firstChild;)e.firstChild.remove()},t.clearNodeRecursively=function e(t){for(;t.firstChild;){const i=t.firstChild;i.remove(),e(i)}},t.addDisposableListener=C,t.addDisposableGenericMouseDownListener=w,t.addDisposableGenericMouseMoveListener=function(e,i,s){return C(e,g.isIOS&&l.BrowserFeatures.pointerEvents?t.EventType.POINTER_MOVE:t.EventType.MOUSE_MOVE,i,s)},t.addDisposableGenericMouseUpListener=E,t.runWhenWindowIdle=function(e,t,i){return(0,d._runWhenIdle)(e,t,i)},t.disposableWindowInterval=function(e,t,i,s){let r=0;const n=e.setInterval((()=>{r++,("number"==typeof s&&r>=s||!0===t())&&o.dispose()}),i),o=(0,p.toDisposable)((()=>{e.clearInterval(n)}));return o},t.measure=function(e,i){return(0,t.scheduleAtNextAnimationFrame)(e,i,1e4)},t.modify=function(e,i){return(0,t.scheduleAtNextAnimationFrame)(e,i,-1e4)},t.addDisposableThrottledListener=function(e,t,i,s,r){return new T(e,t,i,s,r)},t.getComputedStyle=k,t.getClientArea=function e(i,s){const r=(0,t.getWindow)(i),n=r.document;if(i!==n.body)return new O(i.clientWidth,i.clientHeight);if(g.isIOS&&r?.visualViewport)return new O(r.visualViewport.width,r.visualViewport.height);if(r?.innerWidth&&r.innerHeight)return new O(r.innerWidth,r.innerHeight);if(n.body&&n.body.clientWidth&&n.body.clientHeight)return new O(n.body.clientWidth,n.body.clientHeight);if(n.documentElement&&n.documentElement.clientWidth&&n.documentElement.clientHeight)return new O(n.documentElement.clientWidth,n.documentElement.clientHeight);if(s)return e(s);throw new Error("Unable to figure out browser width and height")},t.getTopLeftOffset=I,t.size=function(e,t,i){"number"==typeof t&&(e.style.width=`${t}px`),"number"==typeof i&&(e.style.height=`${i}px`)},t.position=function(e,t,i,s,r,n="absolute"){"number"==typeof t&&(e.style.top=`${t}px`),"number"==typeof i&&(e.style.right=`${i}px`),"number"==typeof s&&(e.style.bottom=`${s}px`),"number"==typeof r&&(e.style.left=`${r}px`),e.style.position=n},t.getDomNodePagePosition=function(e){const i=e.getBoundingClientRect(),s=(0,t.getWindow)(e);return{left:i.left+s.scrollX,top:i.top+s.scrollY,width:i.width,height:i.height}},t.getDomNodeZoomLevel=function(e){let t=e,i=1;do{const e=k(t).zoom;null!=e&&"1"!==e&&(i*=e),t=t.parentElement}while(null!==t&&t!==t.ownerDocument.documentElement);return i},t.getTotalWidth=P,t.getContentWidth=function(e){const t=M.getBorderLeftWidth(e)+M.getBorderRightWidth(e),i=M.getPaddingLeft(e)+M.getPaddingRight(e);return e.offsetWidth-t-i},t.getTotalScrollWidth=x,t.getContentHeight=function(e){const t=M.getBorderTopWidth(e)+M.getBorderBottomWidth(e),i=M.getPaddingTop(e)+M.getPaddingBottom(e);return e.offsetHeight-t-i},t.getTotalHeight=function(e){const t=M.getMarginTop(e)+M.getMarginBottom(e);return e.offsetHeight+t},t.getLargestChildWidth=function(e,t){const i=t.map((t=>Math.max(x(t),P(t))+function(e,t){if(null===e)return 0;const i=I(e),s=I(t);return i.left-s.left}(t,e)||0));return Math.max(...i)},t.isAncestor=B,t.setParentFlowTo=function(e,t){e.dataset[N]=t.id},t.isAncestorUsingFlowTo=function(e,t){let i=e;for(;i;){if(i===t)return!0;if(Q(i)){const e=U(i);if(e){i=e;continue}}i=i.parentNode}return!1},t.findParentWithClass=F,t.hasParentWithClass=function(e,t,i){return!!F(e,t,i)},t.isShadowRoot=W,t.isInShadowDOM=function(e){return!!H(e)},t.getShadowRoot=H,t.getActiveElement=K,t.isActiveElement=function(e){return K()===e},t.isAncestorOfActiveElement=function(e){return B(K(),e)},t.isActiveDocument=function(e){return e.ownerDocument===z()},t.getActiveDocument=z,t.getActiveWindow=function(){const e=z();return e.defaultView?.window??v.mainWindow},t.isGlobalStylesheet=function(e){return j.has(e)},t.createStyleSheet2=function(){return new $},t.createStyleSheet=V,t.cloneGlobalStylesheets=function(e){const t=new p.DisposableStore;for(const[i,s]of j)t.add(G(i,s,e));return t},t.createMetaElement=function(e=v.mainWindow.document.head){return q("meta",e)},t.createLinkElement=function(e=v.mainWindow.document.head){return q("link",e)},t.createCSSRule=function e(t,i,s=Y()){if(s&&i){s.sheet?.insertRule(`${t} {${i}}`,0);for(const r of j.get(s)??[])e(t,i,r)}},t.removeCSSRulesContainingSelector=function e(t,i=Y()){if(!i)return;const s=Z(i),r=[];for(let e=0;e=0;e--)i.sheet?.deleteRule(r[e]);for(const s of j.get(i)??[])e(t,s)},t.isHTMLElement=Q,t.isHTMLAnchorElement=function(e){return e instanceof HTMLAnchorElement||e instanceof(0,t.getWindow)(e).HTMLAnchorElement},t.isHTMLSpanElement=function(e){return e instanceof HTMLSpanElement||e instanceof(0,t.getWindow)(e).HTMLSpanElement},t.isHTMLTextAreaElement=function(e){return e instanceof HTMLTextAreaElement||e instanceof(0,t.getWindow)(e).HTMLTextAreaElement},t.isHTMLInputElement=function(e){return e instanceof HTMLInputElement||e instanceof(0,t.getWindow)(e).HTMLInputElement},t.isHTMLButtonElement=function(e){return e instanceof HTMLButtonElement||e instanceof(0,t.getWindow)(e).HTMLButtonElement},t.isHTMLDivElement=function(e){return e instanceof HTMLDivElement||e instanceof(0,t.getWindow)(e).HTMLDivElement},t.isSVGElement=function(e){return e instanceof SVGElement||e instanceof(0,t.getWindow)(e).SVGElement},t.isMouseEvent=function(e){return e instanceof MouseEvent||e instanceof(0,t.getWindow)(e).MouseEvent},t.isKeyboardEvent=function(e){return e instanceof KeyboardEvent||e instanceof(0,t.getWindow)(e).KeyboardEvent},t.isPointerEvent=function(e){return e instanceof PointerEvent||e instanceof(0,t.getWindow)(e).PointerEvent},t.isDragEvent=function(e){return e instanceof DragEvent||e instanceof(0,t.getWindow)(e).DragEvent},t.isEventLike=function(e){const t=e;return!(!t||"function"!=typeof t.preventDefault||"function"!=typeof t.stopPropagation)},t.saveParentsScrollTop=function(e){const t=[];for(let i=0;e&&e.nodeType===e.ELEMENT_NODE;i++)t[i]=e.scrollTop,e=e.parentNode;return t},t.restoreParentsScrollTop=function(e,t){for(let i=0;e&&e.nodeType===e.ELEMENT_NODE;i++)e.scrollTop!==t[i]&&(e.scrollTop=t[i]),e=e.parentNode},t.trackFocus=function(e){return new ee(e)},t.after=function(e,t){return e.after(t),t},t.append=te,t.prepend=function(e,t){return e.insertBefore(t,e.firstChild),t},t.reset=function(e,...t){e.innerText="",te(e,...t)},t.$=ne,t.join=function(e,t){const i=[];return e.forEach(((e,s)=>{s>0&&(t instanceof Node?i.push(t.cloneNode()):i.push(document.createTextNode(t))),i.push(e)})),i},t.setVisibility=function(e,...t){e?oe(...t):ae(...t)},t.show=oe,t.hide=ae,t.removeTabIndexAndUpdateFocus=function(e){if(e&&e.hasAttribute("tabIndex")){if(e.ownerDocument.activeElement===e){const t=function(e){for(;e&&e.nodeType===e.ELEMENT_NODE;){if(Q(e)&&e.hasAttribute("tabIndex"))return e;e=e.parentNode}return null}(e.parentElement);t?.focus()}e.removeAttribute("tabindex")}},t.finalHandler=function(e){return t=>{t.preventDefault(),t.stopPropagation(),e(t)}},t.domContentLoaded=function(e){return new Promise((t=>{if("complete"===e.document.readyState||e.document&&null!==e.document.body)t(void 0);else{const i=()=>{e.window.removeEventListener("DOMContentLoaded",i,!1),t()};e.window.addEventListener("DOMContentLoaded",i,!1)}}))},t.computeScreenAwareSize=function(e,t){const i=e.devicePixelRatio*t;return Math.max(1,Math.floor(i))/e.devicePixelRatio},t.windowOpenNoOpener=function(e){v.mainWindow.open(e,"_blank","noopener")},t.windowOpenPopup=function(e){const t=Math.floor(v.mainWindow.screenLeft+v.mainWindow.innerWidth/2-le/2),i=Math.floor(v.mainWindow.screenTop+v.mainWindow.innerHeight/2-he/2);v.mainWindow.open(e,"_blank",`width=${le},height=${he},top=${i},left=${t}`)},t.windowOpenWithSuccess=function(e,t=!0){const i=v.mainWindow.open();return!!i&&(t&&(i.opener=null),i.location.href=e,!0)},t.animate=function(e,i){const s=()=>{i(),r=(0,t.scheduleAtNextAnimationFrame)(e,s)};let r=(0,t.scheduleAtNextAnimationFrame)(e,s);return(0,p.toDisposable)((()=>r.dispose()))},t.asCSSPropertyValue=function(e){return`'${e.replace(/'/g,"%27")}'`},t.asCssValueWithDefault=function e(t,i){if(void 0!==t){const s=t.match(/^\s*var\((.+)\)$/);if(s){const t=s[1].split(",",2);return 2===t.length&&(i=e(t[1].trim(),i)),`var(${t[0]}, ${i})`}return t}return i},t.detectFullscreen=function(e){return e.document.fullscreenElement||e.document.webkitFullscreenElement||e.document.webkitIsFullScreen?{mode:ce.DOCUMENT,guess:!1}:e.innerHeight===e.screen.height?{mode:ce.BROWSER,guess:!1}:(g.isMacintosh||g.isLinux)&&e.outerHeight===e.screen.height&&e.outerWidth===e.screen.width?{mode:ce.BROWSER,guess:!0}:null},t.multibyteAwareBtoa=function(e){return btoa(function(e){const t=new Uint16Array(e.length);for(let i=0;i0&&(o.className=a.join(" "));const l={};if(r.groups.name&&(l[r.groups.name]=o),s)for(const e of s)Q(e)?o.appendChild(e):"string"==typeof e?o.append(e):"root"in e&&(Object.assign(l,e),o.appendChild(e.root));for(const[e,t]of Object.entries(i))if("className"!==e)if("style"===e)for(const[e,i]of Object.entries(t))o.style.setProperty(fe(e),"number"==typeof i?i+"px":""+i);else"tabIndex"===e?o.tabIndex=t:o.setAttribute(fe(e),t.toString());return l.root=o,l},t.svgElem=function(e,...t){let i,s;Array.isArray(t[0])?(i={},s=t[0]):(i=t[0]||{},s=t[1]);const r=_e.exec(e);if(!r||!r.groups)throw new Error("Bad use of h");const n=r.groups.tag||"div",o=document.createElementNS("http://www.w3.org/2000/svg",n);r.groups.id&&(o.id=r.groups.id);const a=[];if(r.groups.class)for(const e of r.groups.class.split("."))""!==e&&a.push(e);if(void 0!==i.className)for(const e of i.className.split("."))""!==e&&a.push(e);a.length>0&&(o.className=a.join(" "));const l={};if(r.groups.name&&(l[r.groups.name]=o),s)for(const e of s)Q(e)?o.appendChild(e):"string"==typeof e?o.append(e):"root"in e&&(Object.assign(l,e),o.appendChild(e.root));for(const[e,t]of Object.entries(i))if("className"!==e)if("style"===e)for(const[e,i]of Object.entries(t))o.style.setProperty(fe(e),"number"==typeof i?i+"px":""+i);else"tabIndex"===e?o.tabIndex=t:o.setAttribute(fe(e),t.toString());return l.root=o,l},t.copyAttributes=pe,t.trackAttributes=function(e,i,s){pe(e,i,s);const r=new p.DisposableStore;return r.add(t.sharedMutationObserver.observe(e,r,{attributes:!0,attributeFilter:s})((t=>{for(const s of t)"attributes"===s.type&&s.attributeName&&ge(e,i,s.attributeName)}))),r};const a=o(i(4333)),l=i(7745),h=i(5394),c=i(5964),d=i(1758),u=i(9807),_=o(i(802)),f=i(7883),p=i(7150),g=o(i(8163)),m=i(6304),v=i(4693),S=i(7704);s=function(){const e=new Map;(0,v.ensureCodeWindow)(v.mainWindow,1);const i={window:v.mainWindow,disposables:new p.DisposableStore};e.set(v.mainWindow.vscodeWindowId,i);const s=new _.Emitter,r=new _.Emitter,n=new _.Emitter;return{onDidRegisterWindow:s.event,onWillUnregisterWindow:n.event,onDidUnregisterWindow:r.event,registerWindow(i){if(e.has(i.vscodeWindowId))return p.Disposable.None;const o=new p.DisposableStore,a={window:i,disposables:o.add(new p.DisposableStore)};return e.set(i.vscodeWindowId,a),o.add((0,p.toDisposable)((()=>{e.delete(i.vscodeWindowId),r.fire(i)}))),o.add(C(i,t.EventType.BEFORE_UNLOAD,(()=>{n.fire(i)}))),s.fire(a),o},getWindows:()=>e.values(),getWindowsCount:()=>e.size,getWindowId:e=>e.vscodeWindowId,hasWindow:t=>e.has(t),getWindowById:function(t,s){return("number"==typeof t?e.get(t):void 0)??(s?i:void 0)},getWindow(e){const t=e;if(t?.ownerDocument?.defaultView)return t.ownerDocument.defaultView.window;const i=e;return i?.view?i.view.window:v.mainWindow},getDocument(e){const i=e;return(0,t.getWindow)(i).document}}}(),t.registerWindow=s.registerWindow,t.getWindow=s.getWindow,t.getDocument=s.getDocument,t.getWindows=s.getWindows,t.getWindowsCount=s.getWindowsCount,t.getWindowId=s.getWindowId,t.getWindowById=s.getWindowById,t.hasWindow=s.hasWindow,t.onDidRegisterWindow=s.onDidRegisterWindow,t.onWillUnregisterWindow=s.onWillUnregisterWindow,t.onDidUnregisterWindow=s.onDidUnregisterWindow;class b{constructor(e,t,i,s){this._node=e,this._type=t,this._handler=i,this._options=s||!1,this._node.addEventListener(this._type,this._handler,this._options)}dispose(){this._handler&&(this._node.removeEventListener(this._type,this._handler,this._options),this._node=null,this._handler=null)}}function C(e,t,i,s){return new b(e,t,i,s)}function y(e,t){return function(i){return t(new c.StandardMouseEvent(e,i))}}function w(e,i,s){return C(e,g.isIOS&&l.BrowserFeatures.pointerEvents?t.EventType.POINTER_DOWN:t.EventType.MOUSE_DOWN,i,s)}function E(e,i,s){return C(e,g.isIOS&&l.BrowserFeatures.pointerEvents?t.EventType.POINTER_UP:t.EventType.MOUSE_UP,i,s)}t.addStandardDisposableListener=function(e,i,s,r){let n=s;return"click"===i||"mousedown"===i||"contextmenu"===i?n=y((0,t.getWindow)(e),s):"keydown"!==i&&"keypress"!==i&&"keyup"!==i||(n=function(e){return function(t){return e(new h.StandardKeyboardEvent(t))}}(s)),C(e,i,n,r)},t.addStandardDisposableGenericMouseDownListener=function(e,i,s){return w(e,y((0,t.getWindow)(e),i),s)},t.addStandardDisposableGenericMouseUpListener=function(e,i,s){return E(e,y((0,t.getWindow)(e),i),s)};class D extends d.AbstractIdleValue{constructor(e,t){super(e,t)}}t.WindowIdleValue=D;class L extends d.IntervalTimer{constructor(e){super(),this.defaultTarget=e&&(0,t.getWindow)(e)}cancelAndSet(e,t,i){return super.cancelAndSet(e,t,i??this.defaultTarget)}}t.WindowIntervalTimer=L;class R{constructor(e,t=0){this._runner=e,this.priority=t,this._canceled=!1}dispose(){this._canceled=!0}execute(){if(!this._canceled)try{this._runner()}catch(e){(0,u.onUnexpectedError)(e)}}static sort(e,t){return t.priority-e.priority}}!function(){const e=new Map,i=new Map,s=new Map,r=new Map;t.scheduleAtNextAnimationFrame=(n,o,a=0)=>{const l=(0,t.getWindowId)(n),h=new R(o,a);let c=e.get(l);return c||(c=[],e.set(l,c)),c.push(h),s.get(l)||(s.set(l,!0),n.requestAnimationFrame((()=>(t=>{s.set(t,!1);const n=e.get(t)??[];for(i.set(t,n),e.set(t,[]),r.set(t,!0);n.length>0;)n.sort(R.sort),n.shift().execute();r.set(t,!1)})(l)))),h},t.runAtThisOrScheduleAtNextAnimationFrame=(e,s,n)=>{const o=(0,t.getWindowId)(e);if(r.get(o)){const e=new R(s,n);let t=i.get(o);return t||(t=[],i.set(o,t)),t.push(e),e}return(0,t.scheduleAtNextAnimationFrame)(e,s,n)}}();const A=function(e,t){return t};class T extends p.Disposable{constructor(e,t,i,s=A,r=8){super();let n=null,o=0;const a=this._register(new d.TimeoutTimer),l=()=>{o=(new Date).getTime(),i(n),n=null};this._register(C(e,t,(e=>{n=s(n,e);const t=(new Date).getTime()-o;t>=r?(a.cancel(),l()):a.setIfNotSet(l,r-t)})))}}function k(e){return(0,t.getWindow)(e).getComputedStyle(e,null)}class M{static convertToPixels(e,t){return parseFloat(t)||0}static getDimension(e,t,i){const s=k(e),r=s?s.getPropertyValue(t):"0";return M.convertToPixels(e,r)}static getBorderLeftWidth(e){return M.getDimension(e,"border-left-width","borderLeftWidth")}static getBorderRightWidth(e){return M.getDimension(e,"border-right-width","borderRightWidth")}static getBorderTopWidth(e){return M.getDimension(e,"border-top-width","borderTopWidth")}static getBorderBottomWidth(e){return M.getDimension(e,"border-bottom-width","borderBottomWidth")}static getPaddingLeft(e){return M.getDimension(e,"padding-left","paddingLeft")}static getPaddingRight(e){return M.getDimension(e,"padding-right","paddingRight")}static getPaddingTop(e){return M.getDimension(e,"padding-top","paddingTop")}static getPaddingBottom(e){return M.getDimension(e,"padding-bottom","paddingBottom")}static getMarginLeft(e){return M.getDimension(e,"margin-left","marginLeft")}static getMarginTop(e){return M.getDimension(e,"margin-top","marginTop")}static getMarginRight(e){return M.getDimension(e,"margin-right","marginRight")}static getMarginBottom(e){return M.getDimension(e,"margin-bottom","marginBottom")}}class O{static{this.None=new O(0,0)}constructor(e,t){this.width=e,this.height=t}with(e=this.width,t=this.height){return e!==this.width||t!==this.height?new O(e,t):this}static is(e){return"object"==typeof e&&"number"==typeof e.height&&"number"==typeof e.width}static lift(e){return e instanceof O?e:new O(e.width,e.height)}static equals(e,t){return e===t||!(!e||!t)&&e.width===t.width&&e.height===t.height}}function I(e){let t=e.offsetParent,i=e.offsetTop,s=e.offsetLeft;for(;null!==(e=e.parentNode)&&e!==e.ownerDocument.body&&e!==e.ownerDocument.documentElement;){i-=e.scrollTop;const r=W(e)?null:k(e);r&&(s-="rtl"!==r.direction?e.scrollLeft:-e.scrollLeft),e===t&&(s+=M.getBorderLeftWidth(e),i+=M.getBorderTopWidth(e),i+=e.offsetTop,s+=e.offsetLeft,t=e.offsetParent)}return{left:s,top:i}}function P(e){const t=M.getMarginLeft(e)+M.getMarginRight(e);return e.offsetWidth+t}function x(e){const t=M.getMarginLeft(e)+M.getMarginRight(e);return e.scrollWidth+t}function B(e,t){return Boolean(t?.contains(e))}t.Dimension=O;const N="parentFlowToElementId";function U(e){const t=e.dataset[N];return"string"==typeof t?e.ownerDocument.getElementById(t):null}function F(e,t,i){for(;e&&e.nodeType===e.ELEMENT_NODE;){if(e.classList.contains(t))return e;if(i)if("string"==typeof i){if(e.classList.contains(i))return null}else if(e===i)return null;e=e.parentNode}return null}function W(e){return e&&!!e.host&&!!e.mode}function H(e){for(;e.parentNode;){if(e===e.ownerDocument?.body)return null;e=e.parentNode}return W(e)?e:null}function K(){let e=z().activeElement;for(;e?.shadowRoot;)e=e.shadowRoot.activeElement;return e}function z(){return(0,t.getWindowsCount)()<=1?v.mainWindow.document:Array.from((0,t.getWindows)()).map((({window:e})=>e.document)).find((e=>e.hasFocus()))??v.mainWindow.document}const j=new Map;class ${constructor(){this._currentCssStyle="",this._styleSheet=void 0}setStyle(e){e!==this._currentCssStyle&&(this._currentCssStyle=e,this._styleSheet?this._styleSheet.innerText=e:this._styleSheet=V(v.mainWindow.document.head,(t=>t.innerText=e)))}dispose(){this._styleSheet&&(this._styleSheet.remove(),this._styleSheet=void 0)}}function V(e=v.mainWindow.document.head,i,s){const r=document.createElement("style");if(r.type="text/css",r.media="screen",i?.(r),e.appendChild(r),s&&s.add((0,p.toDisposable)((()=>r.remove()))),e===v.mainWindow.document.head){const e=new Set;j.set(r,e);for(const{window:i,disposables:n}of(0,t.getWindows)()){if(i===v.mainWindow)continue;const t=n.add(G(r,e,i));s?.add(t)}}return r}function G(e,i,s){const r=new p.DisposableStore,n=e.cloneNode(!0);s.document.head.appendChild(n),r.add((0,p.toDisposable)((()=>n.remove())));for(const t of Z(e))n.sheet?.insertRule(t.cssText,n.sheet?.cssRules.length);return r.add(t.sharedMutationObserver.observe(e,r,{childList:!0})((()=>{n.textContent=e.textContent}))),i.add(n),r.add((0,p.toDisposable)((()=>i.delete(n)))),r}function q(e,t=v.mainWindow.document.head){const i=document.createElement(e);return t.appendChild(i),i}t.sharedMutationObserver=new class{constructor(){this.mutationObservers=new Map}observe(e,t,i){let s=this.mutationObservers.get(e);s||(s=new Map,this.mutationObservers.set(e,s));const r=(0,m.hash)(i);let n=s.get(r);if(n)n.users+=1;else{const o=new _.Emitter,a=new MutationObserver((e=>o.fire(e)));a.observe(e,i);const l=n={users:1,observer:a,onDidMutate:o.event};t.add((0,p.toDisposable)((()=>{l.users-=1,0===l.users&&(o.dispose(),a.disconnect(),s?.delete(r),0===s?.size&&this.mutationObservers.delete(e))}))),s.set(r,n)}return n.onDidMutate}};let X=null;function Y(){return X||(X=V()),X}function Z(e){return e?.sheet?.rules?e.sheet.rules:e?.sheet?.cssRules?e.sheet.cssRules:[]}function J(e){return"string"==typeof e.selectorText}function Q(e){return e instanceof HTMLElement||e instanceof(0,t.getWindow)(e).HTMLElement}t.EventType={CLICK:"click",AUXCLICK:"auxclick",DBLCLICK:"dblclick",MOUSE_UP:"mouseup",MOUSE_DOWN:"mousedown",MOUSE_OVER:"mouseover",MOUSE_MOVE:"mousemove",MOUSE_OUT:"mouseout",MOUSE_ENTER:"mouseenter",MOUSE_LEAVE:"mouseleave",MOUSE_WHEEL:"wheel",POINTER_UP:"pointerup",POINTER_DOWN:"pointerdown",POINTER_MOVE:"pointermove",POINTER_LEAVE:"pointerleave",CONTEXT_MENU:"contextmenu",WHEEL:"wheel",KEY_DOWN:"keydown",KEY_PRESS:"keypress",KEY_UP:"keyup",LOAD:"load",BEFORE_UNLOAD:"beforeunload",UNLOAD:"unload",PAGE_SHOW:"pageshow",PAGE_HIDE:"pagehide",PASTE:"paste",ABORT:"abort",ERROR:"error",RESIZE:"resize",SCROLL:"scroll",FULLSCREEN_CHANGE:"fullscreenchange",WK_FULLSCREEN_CHANGE:"webkitfullscreenchange",SELECT:"select",CHANGE:"change",SUBMIT:"submit",RESET:"reset",FOCUS:"focus",FOCUS_IN:"focusin",FOCUS_OUT:"focusout",BLUR:"blur",INPUT:"input",STORAGE:"storage",DRAG_START:"dragstart",DRAG:"drag",DRAG_ENTER:"dragenter",DRAG_LEAVE:"dragleave",DRAG_OVER:"dragover",DROP:"drop",DRAG_END:"dragend",ANIMATION_START:a.isWebKit?"webkitAnimationStart":"animationstart",ANIMATION_END:a.isWebKit?"webkitAnimationEnd":"animationend",ANIMATION_ITERATION:a.isWebKit?"webkitAnimationIteration":"animationiteration"},t.EventHelper={stop:(e,t)=>(e.preventDefault(),t&&e.stopPropagation(),e)};class ee extends p.Disposable{static hasFocusWithin(e){if(Q(e)){const t=H(e);return B(t?t.activeElement:e.ownerDocument.activeElement,e)}{const t=e;return B(t.document.activeElement,t.document)}}constructor(e){super(),this._onDidFocus=this._register(new _.Emitter),this.onDidFocus=this._onDidFocus.event,this._onDidBlur=this._register(new _.Emitter),this.onDidBlur=this._onDidBlur.event;let i=ee.hasFocusWithin(e),s=!1;const r=()=>{s=!1,i||(i=!0,this._onDidFocus.fire())},n=()=>{i&&(s=!0,(Q(e)?(0,t.getWindow)(e):e).setTimeout((()=>{s&&(s=!1,i=!1,this._onDidBlur.fire())}),0))};this._refreshStateHandler=()=>{ee.hasFocusWithin(e)!==i&&(i?n():r())},this._register(C(e,t.EventType.FOCUS,r,!0)),this._register(C(e,t.EventType.BLUR,n,!0)),Q(e)&&(this._register(C(e,t.EventType.FOCUS_IN,(()=>this._refreshStateHandler()))),this._register(C(e,t.EventType.FOCUS_OUT,(()=>this._refreshStateHandler()))))}refreshState(){this._refreshStateHandler()}}function te(e,...t){if(e.append(...t),1===t.length&&"string"!=typeof t[0])return t[0]}const ie=/([\w\-]+)?(#([\w\-]+))?((\.([\w\-]+))*)/;var se;function re(e,t,i,...s){const r=ie.exec(t);if(!r)throw new Error("Bad use of emmet");const n=r[1]||"div";let o;return o=e!==se.HTML?document.createElementNS(e,n):document.createElement(n),r[3]&&(o.id=r[3]),r[4]&&(o.className=r[4].replace(/\./g," ").trim()),i&&Object.entries(i).forEach((([e,t])=>{void 0!==t&&(/^on\w+$/.test(e)?o[e]=t:"selected"===e?t&&o.setAttribute(e,"true"):o.setAttribute(e,t))})),o.append(...s),o}function ne(e,t,...i){return re(se.HTML,e,t,...i)}function oe(...e){for(const t of e)t.style.display="",t.removeAttribute("aria-hidden")}function ae(...e){for(const t of e)t.style.display="none",t.setAttribute("aria-hidden","true")}!function(e){e.HTML="http://www.w3.org/1999/xhtml",e.SVG="http://www.w3.org/2000/svg"}(se||(t.Namespace=se={})),ne.SVG=function(e,t,...i){return re(se.SVG,e,t,...i)};const le=780,he=640;var ce;!function(e){e[e.DOCUMENT=1]="DOCUMENT",e[e.BROWSER=2]="BROWSER"}(ce||(t.DetectedFullscreenMode=ce={}));class de extends _.Emitter{constructor(){super(),this._subscriptions=new p.DisposableStore,this._keyStatus={altKey:!1,shiftKey:!1,ctrlKey:!1,metaKey:!1},this._subscriptions.add(_.Event.runAndSubscribe(t.onDidRegisterWindow,(({window:e,disposables:t})=>this.registerListeners(e,t)),{window:v.mainWindow,disposables:this._subscriptions}))}registerListeners(e,t){t.add(C(e,"keydown",(e=>{if(e.defaultPrevented)return;const t=new h.StandardKeyboardEvent(e);if(t.keyCode!==f.KeyCode.Alt||!e.repeat){if(e.altKey&&!this._keyStatus.altKey)this._keyStatus.lastKeyPressed="alt";else if(e.ctrlKey&&!this._keyStatus.ctrlKey)this._keyStatus.lastKeyPressed="ctrl";else if(e.metaKey&&!this._keyStatus.metaKey)this._keyStatus.lastKeyPressed="meta";else if(e.shiftKey&&!this._keyStatus.shiftKey)this._keyStatus.lastKeyPressed="shift";else{if(t.keyCode===f.KeyCode.Alt)return;this._keyStatus.lastKeyPressed=void 0}this._keyStatus.altKey=e.altKey,this._keyStatus.ctrlKey=e.ctrlKey,this._keyStatus.metaKey=e.metaKey,this._keyStatus.shiftKey=e.shiftKey,this._keyStatus.lastKeyPressed&&(this._keyStatus.event=e,this.fire(this._keyStatus))}}),!0)),t.add(C(e,"keyup",(e=>{e.defaultPrevented||(!e.altKey&&this._keyStatus.altKey?this._keyStatus.lastKeyReleased="alt":!e.ctrlKey&&this._keyStatus.ctrlKey?this._keyStatus.lastKeyReleased="ctrl":!e.metaKey&&this._keyStatus.metaKey?this._keyStatus.lastKeyReleased="meta":!e.shiftKey&&this._keyStatus.shiftKey?this._keyStatus.lastKeyReleased="shift":this._keyStatus.lastKeyReleased=void 0,this._keyStatus.lastKeyPressed!==this._keyStatus.lastKeyReleased&&(this._keyStatus.lastKeyPressed=void 0),this._keyStatus.altKey=e.altKey,this._keyStatus.ctrlKey=e.ctrlKey,this._keyStatus.metaKey=e.metaKey,this._keyStatus.shiftKey=e.shiftKey,this._keyStatus.lastKeyReleased&&(this._keyStatus.event=e,this.fire(this._keyStatus)))}),!0)),t.add(C(e.document.body,"mousedown",(()=>{this._keyStatus.lastKeyPressed=void 0}),!0)),t.add(C(e.document.body,"mouseup",(()=>{this._keyStatus.lastKeyPressed=void 0}),!0)),t.add(C(e.document.body,"mousemove",(e=>{e.buttons&&(this._keyStatus.lastKeyPressed=void 0)}),!0)),t.add(C(e,"blur",(()=>{this.resetKeyStatus()})))}get keyStatus(){return this._keyStatus}get isModifierPressed(){return this._keyStatus.altKey||this._keyStatus.ctrlKey||this._keyStatus.metaKey||this._keyStatus.shiftKey}resetKeyStatus(){this.doResetKeyStatus(),this.fire(this._keyStatus)}doResetKeyStatus(){this._keyStatus={altKey:!1,shiftKey:!1,ctrlKey:!1,metaKey:!1}}static getInstance(){return de.instance||(de.instance=new de),de.instance}dispose(){super.dispose(),this._subscriptions.dispose()}}t.ModifierKeyEmitter=de;class ue extends p.Disposable{constructor(e,t){super(),this.element=e,this.callbacks=t,this.counter=0,this.dragStartTime=0,this.registerListeners()}registerListeners(){this.callbacks.onDragStart&&this._register(C(this.element,t.EventType.DRAG_START,(e=>{this.callbacks.onDragStart?.(e)}))),this.callbacks.onDrag&&this._register(C(this.element,t.EventType.DRAG,(e=>{this.callbacks.onDrag?.(e)}))),this._register(C(this.element,t.EventType.DRAG_ENTER,(e=>{this.counter++,this.dragStartTime=e.timeStamp,this.callbacks.onDragEnter?.(e)}))),this._register(C(this.element,t.EventType.DRAG_OVER,(e=>{e.preventDefault(),this.callbacks.onDragOver?.(e,e.timeStamp-this.dragStartTime)}))),this._register(C(this.element,t.EventType.DRAG_LEAVE,(e=>{this.counter--,0===this.counter&&(this.dragStartTime=0,this.callbacks.onDragLeave?.(e))}))),this._register(C(this.element,t.EventType.DRAG_END,(e=>{this.counter=0,this.dragStartTime=0,this.callbacks.onDragEnd?.(e)}))),this._register(C(this.element,t.EventType.DROP,(e=>{this.counter=0,this.dragStartTime=0,this.callbacks.onDrop?.(e)})))}}t.DragAndDropObserver=ue;const _e=/(?[\w\-]+)?(?:#(?[\w\-]+))?(?(?:\.(?:[\w\-]+))*)(?:@(?(?:[\w\_])+))?/;function fe(e){return e.replace(/([a-z])([A-Z])/g,"$1-$2").toLowerCase()}function pe(e,t,i){for(const{name:s,value:r}of e.attributes)i&&!i.includes(s)||t.setAttribute(s,r)}function ge(e,t,i){const s=e.getAttribute(i);s?t.setAttribute(i,s):t.removeAttribute(i)}t.SafeTriangle=class{constructor(e,t,i){this.originX=e,this.originY=t,this.triangles=[];const{top:s,left:r,right:n,bottom:o}=i.getBoundingClientRect(),a=this.triangles;let l=0;a[l++]=r,a[l++]=s,a[l++]=n,a[l++]=s,a[l++]=r,a[l++]=s,a[l++]=r,a[l++]=o,a[l++]=n,a[l++]=s,a[l++]=n,a[l++]=o,a[l++]=r,a[l++]=o,a[l++]=n,a[l++]=o}contains(e,t){const{triangles:i,originX:s,originY:r}=this;for(let n=0;n<4;n++)if((0,S.isPointWithinTriangle)(e,t,s,r,i[2*n],i[2*n+1],i[2*n+2],i[2*n+3]))return!0;return!1}}},9675:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.FastDomNode=void 0,t.createFastDomNode=function(e){return new i(e)};class i{constructor(e){this.domNode=e,this._maxWidth="",this._width="",this._height="",this._top="",this._left="",this._bottom="",this._right="",this._paddingTop="",this._paddingLeft="",this._paddingBottom="",this._paddingRight="",this._fontFamily="",this._fontWeight="",this._fontSize="",this._fontStyle="",this._fontFeatureSettings="",this._fontVariationSettings="",this._textDecoration="",this._lineHeight="",this._letterSpacing="",this._className="",this._display="",this._position="",this._visibility="",this._color="",this._backgroundColor="",this._layerHint=!1,this._contain="none",this._boxShadow=""}setMaxWidth(e){const t=s(e);this._maxWidth!==t&&(this._maxWidth=t,this.domNode.style.maxWidth=this._maxWidth)}setWidth(e){const t=s(e);this._width!==t&&(this._width=t,this.domNode.style.width=this._width)}setHeight(e){const t=s(e);this._height!==t&&(this._height=t,this.domNode.style.height=this._height)}setTop(e){const t=s(e);this._top!==t&&(this._top=t,this.domNode.style.top=this._top)}setLeft(e){const t=s(e);this._left!==t&&(this._left=t,this.domNode.style.left=this._left)}setBottom(e){const t=s(e);this._bottom!==t&&(this._bottom=t,this.domNode.style.bottom=this._bottom)}setRight(e){const t=s(e);this._right!==t&&(this._right=t,this.domNode.style.right=this._right)}setPaddingTop(e){const t=s(e);this._paddingTop!==t&&(this._paddingTop=t,this.domNode.style.paddingTop=this._paddingTop)}setPaddingLeft(e){const t=s(e);this._paddingLeft!==t&&(this._paddingLeft=t,this.domNode.style.paddingLeft=this._paddingLeft)}setPaddingBottom(e){const t=s(e);this._paddingBottom!==t&&(this._paddingBottom=t,this.domNode.style.paddingBottom=this._paddingBottom)}setPaddingRight(e){const t=s(e);this._paddingRight!==t&&(this._paddingRight=t,this.domNode.style.paddingRight=this._paddingRight)}setFontFamily(e){this._fontFamily!==e&&(this._fontFamily=e,this.domNode.style.fontFamily=this._fontFamily)}setFontWeight(e){this._fontWeight!==e&&(this._fontWeight=e,this.domNode.style.fontWeight=this._fontWeight)}setFontSize(e){const t=s(e);this._fontSize!==t&&(this._fontSize=t,this.domNode.style.fontSize=this._fontSize)}setFontStyle(e){this._fontStyle!==e&&(this._fontStyle=e,this.domNode.style.fontStyle=this._fontStyle)}setFontFeatureSettings(e){this._fontFeatureSettings!==e&&(this._fontFeatureSettings=e,this.domNode.style.fontFeatureSettings=this._fontFeatureSettings)}setFontVariationSettings(e){this._fontVariationSettings!==e&&(this._fontVariationSettings=e,this.domNode.style.fontVariationSettings=this._fontVariationSettings)}setTextDecoration(e){this._textDecoration!==e&&(this._textDecoration=e,this.domNode.style.textDecoration=this._textDecoration)}setLineHeight(e){const t=s(e);this._lineHeight!==t&&(this._lineHeight=t,this.domNode.style.lineHeight=this._lineHeight)}setLetterSpacing(e){const t=s(e);this._letterSpacing!==t&&(this._letterSpacing=t,this.domNode.style.letterSpacing=this._letterSpacing)}setClassName(e){this._className!==e&&(this._className=e,this.domNode.className=this._className)}toggleClassName(e,t){this.domNode.classList.toggle(e,t),this._className=this.domNode.className}setDisplay(e){this._display!==e&&(this._display=e,this.domNode.style.display=this._display)}setPosition(e){this._position!==e&&(this._position=e,this.domNode.style.position=this._position)}setVisibility(e){this._visibility!==e&&(this._visibility=e,this.domNode.style.visibility=this._visibility)}setColor(e){this._color!==e&&(this._color=e,this.domNode.style.color=this._color)}setBackgroundColor(e){this._backgroundColor!==e&&(this._backgroundColor=e,this.domNode.style.backgroundColor=this._backgroundColor)}setLayerHinting(e){this._layerHint!==e&&(this._layerHint=e,this.domNode.style.transform=this._layerHint?"translate3d(0px, 0px, 0px)":"")}setBoxShadow(e){this._boxShadow!==e&&(this._boxShadow=e,this.domNode.style.boxShadow=e)}setContain(e){this._contain!==e&&(this._contain=e,this.domNode.style.contain=this._contain)}setAttribute(e,t){this.domNode.setAttribute(e,t)}removeAttribute(e){this.domNode.removeAttribute(e)}appendChild(e){this.domNode.appendChild(e.domNode)}removeChild(e){this.domNode.removeChild(e.domNode)}}function s(e){return"number"==typeof e?`${e}px`:e}t.FastDomNode=i},8328:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.GlobalPointerMoveMonitor=void 0;const o=n(i(7093)),a=i(7150);t.GlobalPointerMoveMonitor=class{constructor(){this._hooks=new a.DisposableStore,this._pointerMoveCallback=null,this._onStopCallback=null}dispose(){this.stopMonitoring(!1),this._hooks.dispose()}stopMonitoring(e,t){if(!this.isMonitoring())return;this._hooks.clear(),this._pointerMoveCallback=null;const i=this._onStopCallback;this._onStopCallback=null,e&&i&&i(t)}isMonitoring(){return!!this._pointerMoveCallback}startMonitoring(e,t,i,s,r){this.isMonitoring()&&this.stopMonitoring(!1),this._pointerMoveCallback=s,this._onStopCallback=r;let n=e;try{e.setPointerCapture(t),this._hooks.add((0,a.toDisposable)((()=>{try{e.releasePointerCapture(t)}catch(e){}})))}catch(t){n=o.getWindow(e)}this._hooks.add(o.addDisposableListener(n,o.EventType.POINTER_MOVE,(e=>{e.buttons===i?(e.preventDefault(),this._pointerMoveCallback(e)):this.stopMonitoring(!0)}))),this._hooks.add(o.addDisposableListener(n,o.EventType.POINTER_UP,(e=>this.stopMonitoring(!0))))}}},6609:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.IframeUtils=void 0,t.parentOriginHash=async function(e,t){if(!crypto.subtle)throw new Error("'crypto.subtle' is not available so webviews will not work. This is likely because the editor is not running in a secure context (https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts).");const i=JSON.stringify({parentOrigin:e,salt:t}),s=(new TextEncoder).encode(i);return function(e){const t=Array.from(new Uint8Array(e)).map((e=>e.toString(16).padStart(2,"0"))).join("");return BigInt(`0x${t}`).toString(32).padStart(52,"0")}(await crypto.subtle.digest("sha-256",s))};const i=new WeakMap;function s(e){if(!e.parent||e.parent===e)return null;try{const t=e.location,i=e.parent.location;if("null"!==t.origin&&"null"!==i.origin&&t.origin!==i.origin)return null}catch(e){return null}return e.parent}t.IframeUtils=class{static getSameOriginWindowChain(e){let t=i.get(e);if(!t){t=[],i.set(e,t);let r,n=e;do{r=s(n),r?t.push({window:new WeakRef(n),iframeElement:n.frameElement||null}):t.push({window:new WeakRef(n),iframeElement:null}),n=r}while(n)}return t.slice(0)}static getPositionOfChildWindowRelativeToAncestorWindow(e,t){if(!t||e===t)return{top:0,left:0};let i=0,s=0;const r=this.getSameOriginWindowChain(e);for(const e of r){const r=e.window.deref();if(i+=r?.scrollY??0,s+=r?.scrollX??0,r===t)break;if(!e.iframeElement)break;const n=e.iframeElement.getBoundingClientRect();i+=n.top,s+=n.left}return{top:i,left:s}}}},5394:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.StandardKeyboardEvent=void 0,t.printKeyboardEvent=function(e){const t=[];return e.ctrlKey&&t.push("ctrl"),e.shiftKey&&t.push("shift"),e.altKey&&t.push("alt"),e.metaKey&&t.push("meta"),`modifiers: [${t.join(",")}], code: ${e.code}, keyCode: ${e.keyCode}, key: ${e.key}`},t.printStandardKeyboardEvent=function(e){const t=[];return e.ctrlKey&&t.push("ctrl"),e.shiftKey&&t.push("shift"),e.altKey&&t.push("alt"),e.metaKey&&t.push("meta"),`modifiers: [${t.join(",")}], code: ${e.code}, keyCode: ${e.keyCode} ('${a.KeyCodeUtils.toString(e.keyCode)}')`};const o=n(i(4333)),a=i(7883),l=i(2811),h=n(i(8163)),c=h.isMacintosh?a.KeyMod.WinCtrl:a.KeyMod.CtrlCmd,d=a.KeyMod.Alt,u=a.KeyMod.Shift,_=h.isMacintosh?a.KeyMod.CtrlCmd:a.KeyMod.WinCtrl;t.StandardKeyboardEvent=class{constructor(e){this._standardKeyboardEventBrand=!0;const t=e;this.browserEvent=t,this.target=t.target,this.ctrlKey=t.ctrlKey,this.shiftKey=t.shiftKey,this.altKey=t.altKey,this.metaKey=t.metaKey,this.altGraphKey=t.getModifierState?.("AltGraph"),this.keyCode=function(e){if(e.charCode){const t=String.fromCharCode(e.charCode).toUpperCase();return a.KeyCodeUtils.fromString(t)}const t=e.keyCode;if(3===t)return a.KeyCode.PauseBreak;if(o.isFirefox)switch(t){case 59:return a.KeyCode.Semicolon;case 60:if(h.isLinux)return a.KeyCode.IntlBackslash;break;case 61:return a.KeyCode.Equal;case 107:return a.KeyCode.NumpadAdd;case 109:return a.KeyCode.NumpadSubtract;case 173:return a.KeyCode.Minus;case 224:if(h.isMacintosh)return a.KeyCode.Meta}else if(o.isWebKit){if(h.isMacintosh&&93===t)return a.KeyCode.Meta;if(!h.isMacintosh&&92===t)return a.KeyCode.Meta}return a.EVENT_KEY_CODE_MAP[t]||a.KeyCode.Unknown}(t),this.code=t.code,this.ctrlKey=this.ctrlKey||this.keyCode===a.KeyCode.Ctrl,this.altKey=this.altKey||this.keyCode===a.KeyCode.Alt,this.shiftKey=this.shiftKey||this.keyCode===a.KeyCode.Shift,this.metaKey=this.metaKey||this.keyCode===a.KeyCode.Meta,this._asKeybinding=this._computeKeybinding(),this._asKeyCodeChord=this._computeKeyCodeChord()}preventDefault(){this.browserEvent&&this.browserEvent.preventDefault&&this.browserEvent.preventDefault()}stopPropagation(){this.browserEvent&&this.browserEvent.stopPropagation&&this.browserEvent.stopPropagation()}toKeyCodeChord(){return this._asKeyCodeChord}equals(e){return this._asKeybinding===e}_computeKeybinding(){let e=a.KeyCode.Unknown;this.keyCode!==a.KeyCode.Ctrl&&this.keyCode!==a.KeyCode.Shift&&this.keyCode!==a.KeyCode.Alt&&this.keyCode!==a.KeyCode.Meta&&(e=this.keyCode);let t=0;return this.ctrlKey&&(t|=c),this.altKey&&(t|=d),this.shiftKey&&(t|=u),this.metaKey&&(t|=_),t|=e,t}_computeKeyCodeChord(){let e=a.KeyCode.Unknown;return this.keyCode!==a.KeyCode.Ctrl&&this.keyCode!==a.KeyCode.Shift&&this.keyCode!==a.KeyCode.Alt&&this.keyCode!==a.KeyCode.Meta&&(e=this.keyCode),new l.KeyCodeChord(this.ctrlKey,this.shiftKey,this.altKey,this.metaKey,e)}}},5964:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.StandardWheelEvent=t.DragMouseEvent=t.StandardMouseEvent=void 0;const o=n(i(4333)),a=i(6609),l=n(i(8163));class h{constructor(e,t){this.timestamp=Date.now(),this.browserEvent=t,this.leftButton=0===t.button,this.middleButton=1===t.button,this.rightButton=2===t.button,this.buttons=t.buttons,this.target=t.target,this.detail=t.detail||1,"dblclick"===t.type&&(this.detail=2),this.ctrlKey=t.ctrlKey,this.shiftKey=t.shiftKey,this.altKey=t.altKey,this.metaKey=t.metaKey,"number"==typeof t.pageX?(this.posx=t.pageX,this.posy=t.pageY):(this.posx=t.clientX+this.target.ownerDocument.body.scrollLeft+this.target.ownerDocument.documentElement.scrollLeft,this.posy=t.clientY+this.target.ownerDocument.body.scrollTop+this.target.ownerDocument.documentElement.scrollTop);const i=a.IframeUtils.getPositionOfChildWindowRelativeToAncestorWindow(e,t.view);this.posx-=i.left,this.posy-=i.top}preventDefault(){this.browserEvent.preventDefault()}stopPropagation(){this.browserEvent.stopPropagation()}}t.StandardMouseEvent=h,t.DragMouseEvent=class extends h{constructor(e,t){super(e,t),this.dataTransfer=t.dataTransfer}},t.StandardWheelEvent=class{constructor(e,t=0,i=0){this.browserEvent=e||null,this.target=e?e.target||e.targetNode||e.srcElement:null,this.deltaY=i,this.deltaX=t;let s=!1;if(o.isChrome){const e=navigator.userAgent.match(/Chrome\/(\d+)/);s=(e?parseInt(e[1]):123)<=122}if(e){const t=e,i=e,r=e.view?.devicePixelRatio||1;if(void 0!==t.wheelDeltaY)this.deltaY=s?t.wheelDeltaY/(120*r):t.wheelDeltaY/120;else if(void 0!==i.VERTICAL_AXIS&&i.axis===i.VERTICAL_AXIS)this.deltaY=-i.detail/3;else if("wheel"===e.type){const t=e;t.deltaMode===t.DOM_DELTA_LINE?o.isFirefox&&!l.isMacintosh?this.deltaY=-e.deltaY/3:this.deltaY=-e.deltaY:this.deltaY=-e.deltaY/40}if(void 0!==t.wheelDeltaX)o.isSafari&&l.isWindows?this.deltaX=-t.wheelDeltaX/120:this.deltaX=s?t.wheelDeltaX/(120*r):t.wheelDeltaX/120;else if(void 0!==i.HORIZONTAL_AXIS&&i.axis===i.HORIZONTAL_AXIS)this.deltaX=-e.detail/3;else if("wheel"===e.type){const t=e;t.deltaMode===t.DOM_DELTA_LINE?o.isFirefox&&!l.isMacintosh?this.deltaX=-e.deltaX/3:this.deltaX=-e.deltaX:this.deltaX=-e.deltaX/40}0===this.deltaY&&0===this.deltaX&&e.wheelDelta&&(this.deltaY=s?e.wheelDelta/(120*r):e.wheelDelta/120)}}preventDefault(){this.browserEvent?.preventDefault()}stopPropagation(){this.browserEvent?.stopPropagation()}}},8594:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},o=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.Gesture=t.EventType=void 0;const a=o(i(7093)),l=i(4693),h=o(i(3058)),c=i(4838),d=i(802),u=i(7150),_=i(6317);var f;!function(e){e.Tap="-xterm-gesturetap",e.Change="-xterm-gesturechange",e.Start="-xterm-gesturestart",e.End="-xterm-gesturesend",e.Contextmenu="-xterm-gesturecontextmenu"}(f||(t.EventType=f={}));class p extends u.Disposable{static{this.SCROLL_FRICTION=-.005}static{this.HOLD_DELAY=700}static{this.CLEAR_TAP_COUNT_TIME=400}constructor(){super(),this.dispatched=!1,this.targets=new _.LinkedList,this.ignoreTargets=new _.LinkedList,this.activeTouches={},this.handle=null,this._lastSetTapCountTime=0,this._register(d.Event.runAndSubscribe(a.onDidRegisterWindow,(({window:e,disposables:t})=>{t.add(a.addDisposableListener(e.document,"touchstart",(e=>this.onTouchStart(e)),{passive:!1})),t.add(a.addDisposableListener(e.document,"touchend",(t=>this.onTouchEnd(e,t)))),t.add(a.addDisposableListener(e.document,"touchmove",(e=>this.onTouchMove(e)),{passive:!1}))}),{window:l.mainWindow,disposables:this._store}))}static addTarget(e){if(!p.isTouchDevice())return u.Disposable.None;p.INSTANCE||(p.INSTANCE=(0,u.markAsSingleton)(new p));const t=p.INSTANCE.targets.push(e);return(0,u.toDisposable)(t)}static ignoreTarget(e){if(!p.isTouchDevice())return u.Disposable.None;p.INSTANCE||(p.INSTANCE=(0,u.markAsSingleton)(new p));const t=p.INSTANCE.ignoreTargets.push(e);return(0,u.toDisposable)(t)}static isTouchDevice(){return"ontouchstart"in l.mainWindow||navigator.maxTouchPoints>0}dispose(){this.handle&&(this.handle.dispose(),this.handle=null),super.dispose()}onTouchStart(e){const t=Date.now();this.handle&&(this.handle.dispose(),this.handle=null);for(let i=0,s=e.targetTouches.length;i=p.HOLD_DELAY&&Math.abs(o.initialPageX-h.tail(o.rollingPageX))<30&&Math.abs(o.initialPageY-h.tail(o.rollingPageY))<30){const e=this.newGestureEvent(f.Contextmenu,o.initialTarget);e.pageX=h.tail(o.rollingPageX),e.pageY=h.tail(o.rollingPageY),this.dispatchEvent(e)}else if(1===s){const t=h.tail(o.rollingPageX),s=h.tail(o.rollingPageY),r=h.tail(o.rollingTimestamps)-o.rollingTimestamps[0],n=t-o.rollingPageX[0],a=s-o.rollingPageY[0],l=[...this.targets].filter((e=>o.initialTarget instanceof Node&&e.contains(o.initialTarget)));this.inertia(e,l,i,Math.abs(n)/r,n>0?1:-1,t,Math.abs(a)/r,a>0?1:-1,s)}this.dispatchEvent(this.newGestureEvent(f.End,o.initialTarget)),delete this.activeTouches[n.identifier]}this.dispatched&&(t.preventDefault(),t.stopPropagation(),this.dispatched=!1)}newGestureEvent(e,t){const i=document.createEvent("CustomEvent");return i.initEvent(e,!1,!0),i.initialTarget=t,i.tapCount=0,i}dispatchEvent(e){if(e.type===f.Tap){const t=(new Date).getTime();let i=0;i=t-this._lastSetTapCountTime>p.CLEAR_TAP_COUNT_TIME?1:2,this._lastSetTapCountTime=t,e.tapCount=i}else e.type!==f.Change&&e.type!==f.Contextmenu||(this._lastSetTapCountTime=0);if(e.initialTarget instanceof Node){for(const t of this.ignoreTargets)if(t.contains(e.initialTarget))return;const t=[];for(const i of this.targets)if(i.contains(e.initialTarget)){let s=0,r=e.initialTarget;for(;r&&r!==i;)s++,r=r.parentElement;t.push([s,i])}t.sort(((e,t)=>e[0]-t[0]));for(const[i,s]of t)s.dispatchEvent(e),this.dispatched=!0}}inertia(e,t,i,s,r,n,o,l,h){this.handle=a.scheduleAtNextAnimationFrame(e,(()=>{const a=Date.now(),c=a-i;let d=0,u=0,_=!0;s+=p.SCROLL_FRICTION*c,o+=p.SCROLL_FRICTION*c,s>0&&(_=!1,d=r*s*c),o>0&&(_=!1,u=l*o*c);const g=this.newGestureEvent(f.Change);g.translationX=d,g.translationY=u,t.forEach((e=>e.dispatchEvent(g))),_||this.inertia(e,t,a,s,r,n+d,o,l,h+u)}))}onTouchMove(e){const t=Date.now();for(let i=0,s=e.changedTouches.length;i3&&(r.rollingPageX.shift(),r.rollingPageY.shift(),r.rollingTimestamps.shift()),r.rollingPageX.push(s.pageX),r.rollingPageY.push(s.pageY),r.rollingTimestamps.push(t)}this.dispatched&&(e.preventDefault(),e.stopPropagation(),this.dispatched=!1)}}t.Gesture=p,n([c.memoize],p,"isTouchDevice",null)},8801:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.AbstractScrollbar=void 0;const o=n(i(7093)),a=i(9675),l=i(8328),h=i(8974),c=i(79),d=i(8286),u=n(i(8163));class _ extends d.Widget{constructor(e){super(),this._lazyRender=e.lazyRender,this._host=e.host,this._scrollable=e.scrollable,this._scrollByPage=e.scrollByPage,this._scrollbarState=e.scrollbarState,this._visibilityController=this._register(new c.ScrollbarVisibilityController(e.visibility,"visible scrollbar "+e.extraScrollbarClassName,"invisible scrollbar "+e.extraScrollbarClassName)),this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._pointerMoveMonitor=this._register(new l.GlobalPointerMoveMonitor),this._shouldRender=!0,this.domNode=(0,a.createFastDomNode)(document.createElement("div")),this.domNode.setAttribute("role","presentation"),this.domNode.setAttribute("aria-hidden","true"),this._visibilityController.setDomNode(this.domNode),this.domNode.setPosition("absolute"),this._register(o.addDisposableListener(this.domNode.domNode,o.EventType.POINTER_DOWN,(e=>this._domNodePointerDown(e))))}_createArrow(e){const t=this._register(new h.ScrollbarArrow(e));this.domNode.domNode.appendChild(t.bgDomNode),this.domNode.domNode.appendChild(t.domNode)}_createSlider(e,t,i,s){this.slider=(0,a.createFastDomNode)(document.createElement("div")),this.slider.setClassName("slider"),this.slider.setPosition("absolute"),this.slider.setTop(e),this.slider.setLeft(t),"number"==typeof i&&this.slider.setWidth(i),"number"==typeof s&&this.slider.setHeight(s),this.slider.setLayerHinting(!0),this.slider.setContain("strict"),this.domNode.domNode.appendChild(this.slider.domNode),this._register(o.addDisposableListener(this.slider.domNode,o.EventType.POINTER_DOWN,(e=>{0===e.button&&(e.preventDefault(),this._sliderPointerDown(e))}))),this.onclick(this.slider.domNode,(e=>{e.leftButton&&e.stopPropagation()}))}_onElementSize(e){return this._scrollbarState.setVisibleSize(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}_onElementScrollSize(e){return this._scrollbarState.setScrollSize(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}_onElementScrollPosition(e){return this._scrollbarState.setScrollPosition(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}beginReveal(){this._visibilityController.setShouldBeVisible(!0)}beginHide(){this._visibilityController.setShouldBeVisible(!1)}render(){this._shouldRender&&(this._shouldRender=!1,this._renderDomNode(this._scrollbarState.getRectangleLargeSize(),this._scrollbarState.getRectangleSmallSize()),this._updateSlider(this._scrollbarState.getSliderSize(),this._scrollbarState.getArrowSize()+this._scrollbarState.getSliderPosition()))}_domNodePointerDown(e){e.target===this.domNode.domNode&&this._onPointerDown(e)}delegatePointerDown(e){const t=this.domNode.domNode.getClientRects()[0].top,i=t+this._scrollbarState.getSliderPosition(),s=t+this._scrollbarState.getSliderPosition()+this._scrollbarState.getSliderSize(),r=this._sliderPointerPosition(e);i<=r&&r<=s?0===e.button&&(e.preventDefault(),this._sliderPointerDown(e)):this._onPointerDown(e)}_onPointerDown(e){let t,i;if(e.target===this.domNode.domNode&&"number"==typeof e.offsetX&&"number"==typeof e.offsetY)t=e.offsetX,i=e.offsetY;else{const s=o.getDomNodePagePosition(this.domNode.domNode);t=e.pageX-s.left,i=e.pageY-s.top}const s=this._pointerDownRelativePosition(t,i);this._setDesiredScrollPositionNow(this._scrollByPage?this._scrollbarState.getDesiredScrollPositionFromOffsetPaged(s):this._scrollbarState.getDesiredScrollPositionFromOffset(s)),0===e.button&&(e.preventDefault(),this._sliderPointerDown(e))}_sliderPointerDown(e){if(!(e.target&&e.target instanceof Element))return;const t=this._sliderPointerPosition(e),i=this._sliderOrthogonalPointerPosition(e),s=this._scrollbarState.clone();this.slider.toggleClassName("active",!0),this._pointerMoveMonitor.startMonitoring(e.target,e.pointerId,e.buttons,(e=>{const r=this._sliderOrthogonalPointerPosition(e),n=Math.abs(r-i);if(u.isWindows&&n>140)return void this._setDesiredScrollPositionNow(s.getScrollPosition());const o=this._sliderPointerPosition(e)-t;this._setDesiredScrollPositionNow(s.getDesiredScrollPositionFromDelta(o))}),(()=>{this.slider.toggleClassName("active",!1),this._host.onDragEnd()})),this._host.onDragStart()}_setDesiredScrollPositionNow(e){const t={};this.writeScrollPosition(t,e),this._scrollable.setScrollPositionNow(t)}updateScrollbarSize(e){this._updateScrollbarSize(e),this._scrollbarState.setScrollbarSize(e),this._shouldRender=!0,this._lazyRender||this.render()}isNeeded(){return this._scrollbarState.isNeeded()}}t.AbstractScrollbar=_},151:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.HorizontalScrollbar=void 0;const s=i(8801),r=i(8245),n=i(9881);class o extends s.AbstractScrollbar{constructor(e,t,i){const s=e.getScrollDimensions(),o=e.getCurrentScrollPosition();if(super({lazyRender:t.lazyRender,host:i,scrollbarState:new r.ScrollbarState(t.horizontalHasArrows?t.arrowSize:0,t.horizontal===n.ScrollbarVisibility.Hidden?0:t.horizontalScrollbarSize,t.vertical===n.ScrollbarVisibility.Hidden?0:t.verticalScrollbarSize,s.width,s.scrollWidth,o.scrollLeft),visibility:t.horizontal,extraScrollbarClassName:"horizontal",scrollable:e,scrollByPage:t.scrollByPage}),t.horizontalHasArrows)throw new Error("horizontalHasArrows is not supported in xterm.js");this._createSlider(Math.floor((t.horizontalScrollbarSize-t.horizontalSliderSize)/2),0,void 0,t.horizontalSliderSize)}_updateSlider(e,t){this.slider.setWidth(e),this.slider.setLeft(t)}_renderDomNode(e,t){this.domNode.setWidth(e),this.domNode.setHeight(t),this.domNode.setLeft(0),this.domNode.setBottom(0)}onDidScroll(e){return this._shouldRender=this._onElementScrollSize(e.scrollWidth)||this._shouldRender,this._shouldRender=this._onElementScrollPosition(e.scrollLeft)||this._shouldRender,this._shouldRender=this._onElementSize(e.width)||this._shouldRender,this._shouldRender}_pointerDownRelativePosition(e,t){return e}_sliderPointerPosition(e){return e.pageX}_sliderOrthogonalPointerPosition(e){return e.pageY}_updateScrollbarSize(e){this.slider.setHeight(e)}writeScrollPosition(e,t){e.scrollLeft=t}updateOptions(e){this.updateScrollbarSize(e.horizontal===n.ScrollbarVisibility.Hidden?0:e.horizontalScrollbarSize),this._scrollbarState.setOppositeScrollbarSize(e.vertical===n.ScrollbarVisibility.Hidden?0:e.verticalScrollbarSize),this._visibilityController.setVisibility(e.horizontal),this._scrollByPage=e.scrollByPage}}t.HorizontalScrollbar=o},8234:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.DomScrollableElement=t.SmoothScrollableElement=t.ScrollableElement=t.AbstractScrollableElement=t.MouseWheelClassifier=void 0;const o=i(4333),a=n(i(7093)),l=i(9675),h=i(5964),c=i(151),d=i(5473),u=i(8286),_=i(1758),f=i(802),p=i(7150),g=n(i(8163)),m=i(9881);class v{constructor(e,t,i){this.timestamp=e,this.deltaX=t,this.deltaY=i,this.score=0}}class S{static{this.INSTANCE=new S}constructor(){this._capacity=5,this._memory=[],this._front=-1,this._rear=-1}isPhysicalMouseWheel(){if(-1===this._front&&-1===this._rear)return!1;let e=1,t=0,i=1,s=this._rear;for(;;){const r=s===this._front?e:Math.pow(2,-i);if(e-=r,t+=this._memory[s].score*r,s===this._front)break;s=(this._capacity+s-1)%this._capacity,i++}return t<=.5}acceptStandardWheelEvent(e){if(o.isChrome){const t=a.getWindow(e.browserEvent),i=(0,o.getZoomFactor)(t);this.accept(Date.now(),e.deltaX*i,e.deltaY*i)}else this.accept(Date.now(),e.deltaX,e.deltaY)}accept(e,t,i){let s=null;const r=new v(e,t,i);-1===this._front&&-1===this._rear?(this._memory[0]=r,this._front=0,this._rear=0):(s=this._memory[this._rear],this._rear=(this._rear+1)%this._capacity,this._rear===this._front&&(this._front=(this._front+1)%this._capacity),this._memory[this._rear]=r),r.score=this._computeScore(r,s)}_computeScore(e,t){if(Math.abs(e.deltaX)>0&&Math.abs(e.deltaY)>0)return 1;let i=.5;if(this._isAlmostInt(e.deltaX)&&this._isAlmostInt(e.deltaY)||(i+=.25),t){const s=Math.abs(e.deltaX),r=Math.abs(e.deltaY),n=Math.abs(t.deltaX),o=Math.abs(t.deltaY),a=Math.max(Math.min(s,n),1),l=Math.max(Math.min(r,o),1),h=Math.max(s,n),c=Math.max(r,o);h%a==0&&c%l==0&&(i-=.5)}return Math.min(Math.max(i,0),1)}_isAlmostInt(e){return Math.abs(Math.round(e)-e)<.01}}t.MouseWheelClassifier=S;class b extends u.Widget{get options(){return this._options}constructor(e,t,i){super(),this._onScroll=this._register(new f.Emitter),this.onScroll=this._onScroll.event,this._onWillScroll=this._register(new f.Emitter),this.onWillScroll=this._onWillScroll.event,this._options=function(e){const t={lazyRender:void 0!==e.lazyRender&&e.lazyRender,className:void 0!==e.className?e.className:"",useShadows:void 0===e.useShadows||e.useShadows,handleMouseWheel:void 0===e.handleMouseWheel||e.handleMouseWheel,flipAxes:void 0!==e.flipAxes&&e.flipAxes,consumeMouseWheelIfScrollbarIsNeeded:void 0!==e.consumeMouseWheelIfScrollbarIsNeeded&&e.consumeMouseWheelIfScrollbarIsNeeded,alwaysConsumeMouseWheel:void 0!==e.alwaysConsumeMouseWheel&&e.alwaysConsumeMouseWheel,scrollYToX:void 0!==e.scrollYToX&&e.scrollYToX,mouseWheelScrollSensitivity:void 0!==e.mouseWheelScrollSensitivity?e.mouseWheelScrollSensitivity:1,fastScrollSensitivity:void 0!==e.fastScrollSensitivity?e.fastScrollSensitivity:5,scrollPredominantAxis:void 0===e.scrollPredominantAxis||e.scrollPredominantAxis,mouseWheelSmoothScroll:void 0===e.mouseWheelSmoothScroll||e.mouseWheelSmoothScroll,arrowSize:void 0!==e.arrowSize?e.arrowSize:11,listenOnDomNode:void 0!==e.listenOnDomNode?e.listenOnDomNode:null,horizontal:void 0!==e.horizontal?e.horizontal:m.ScrollbarVisibility.Auto,horizontalScrollbarSize:void 0!==e.horizontalScrollbarSize?e.horizontalScrollbarSize:10,horizontalSliderSize:void 0!==e.horizontalSliderSize?e.horizontalSliderSize:0,horizontalHasArrows:void 0!==e.horizontalHasArrows&&e.horizontalHasArrows,vertical:void 0!==e.vertical?e.vertical:m.ScrollbarVisibility.Auto,verticalScrollbarSize:void 0!==e.verticalScrollbarSize?e.verticalScrollbarSize:10,verticalHasArrows:void 0!==e.verticalHasArrows&&e.verticalHasArrows,verticalSliderSize:void 0!==e.verticalSliderSize?e.verticalSliderSize:0,scrollByPage:void 0!==e.scrollByPage&&e.scrollByPage};return t.horizontalSliderSize=void 0!==e.horizontalSliderSize?e.horizontalSliderSize:t.horizontalScrollbarSize,t.verticalSliderSize=void 0!==e.verticalSliderSize?e.verticalSliderSize:t.verticalScrollbarSize,g.isMacintosh&&(t.className+=" mac"),t}(t),this._scrollable=i,this._register(this._scrollable.onScroll((e=>{this._onWillScroll.fire(e),this._onDidScroll(e),this._onScroll.fire(e)})));const s={onMouseWheel:e=>this._onMouseWheel(e),onDragStart:()=>this._onDragStart(),onDragEnd:()=>this._onDragEnd()};this._verticalScrollbar=this._register(new d.VerticalScrollbar(this._scrollable,this._options,s)),this._horizontalScrollbar=this._register(new c.HorizontalScrollbar(this._scrollable,this._options,s)),this._domNode=document.createElement("div"),this._domNode.className="xterm-scrollable-element "+this._options.className,this._domNode.setAttribute("role","presentation"),this._domNode.style.position="relative",this._domNode.appendChild(e),this._domNode.appendChild(this._horizontalScrollbar.domNode.domNode),this._domNode.appendChild(this._verticalScrollbar.domNode.domNode),this._options.useShadows?(this._leftShadowDomNode=(0,l.createFastDomNode)(document.createElement("div")),this._leftShadowDomNode.setClassName("shadow"),this._domNode.appendChild(this._leftShadowDomNode.domNode),this._topShadowDomNode=(0,l.createFastDomNode)(document.createElement("div")),this._topShadowDomNode.setClassName("shadow"),this._domNode.appendChild(this._topShadowDomNode.domNode),this._topLeftShadowDomNode=(0,l.createFastDomNode)(document.createElement("div")),this._topLeftShadowDomNode.setClassName("shadow"),this._domNode.appendChild(this._topLeftShadowDomNode.domNode)):(this._leftShadowDomNode=null,this._topShadowDomNode=null,this._topLeftShadowDomNode=null),this._listenOnDomNode=this._options.listenOnDomNode||this._domNode,this._mouseWheelToDispose=[],this._setListeningToMouseWheel(this._options.handleMouseWheel),this.onmouseover(this._listenOnDomNode,(e=>this._onMouseOver(e))),this.onmouseleave(this._listenOnDomNode,(e=>this._onMouseLeave(e))),this._hideTimeout=this._register(new _.TimeoutTimer),this._isDragging=!1,this._mouseIsOver=!1,this._shouldRender=!0,this._revealOnScroll=!0}dispose(){this._mouseWheelToDispose=(0,p.dispose)(this._mouseWheelToDispose),super.dispose()}getDomNode(){return this._domNode}getOverviewRulerLayoutInfo(){return{parent:this._domNode,insertBefore:this._verticalScrollbar.domNode.domNode}}delegateVerticalScrollbarPointerDown(e){this._verticalScrollbar.delegatePointerDown(e)}getScrollDimensions(){return this._scrollable.getScrollDimensions()}setScrollDimensions(e){this._scrollable.setScrollDimensions(e,!1)}updateClassName(e){this._options.className=e,g.isMacintosh&&(this._options.className+=" mac"),this._domNode.className="xterm-scrollable-element "+this._options.className}updateOptions(e){void 0!==e.handleMouseWheel&&(this._options.handleMouseWheel=e.handleMouseWheel,this._setListeningToMouseWheel(this._options.handleMouseWheel)),void 0!==e.mouseWheelScrollSensitivity&&(this._options.mouseWheelScrollSensitivity=e.mouseWheelScrollSensitivity),void 0!==e.fastScrollSensitivity&&(this._options.fastScrollSensitivity=e.fastScrollSensitivity),void 0!==e.scrollPredominantAxis&&(this._options.scrollPredominantAxis=e.scrollPredominantAxis),void 0!==e.horizontal&&(this._options.horizontal=e.horizontal),void 0!==e.vertical&&(this._options.vertical=e.vertical),void 0!==e.horizontalScrollbarSize&&(this._options.horizontalScrollbarSize=e.horizontalScrollbarSize),void 0!==e.verticalScrollbarSize&&(this._options.verticalScrollbarSize=e.verticalScrollbarSize),void 0!==e.scrollByPage&&(this._options.scrollByPage=e.scrollByPage),this._horizontalScrollbar.updateOptions(this._options),this._verticalScrollbar.updateOptions(this._options),this._options.lazyRender||this._render()}setRevealOnScroll(e){this._revealOnScroll=e}delegateScrollFromMouseWheelEvent(e){this._onMouseWheel(new h.StandardWheelEvent(e))}_setListeningToMouseWheel(e){if(this._mouseWheelToDispose.length>0!==e&&(this._mouseWheelToDispose=(0,p.dispose)(this._mouseWheelToDispose),e)){const e=e=>{this._onMouseWheel(new h.StandardWheelEvent(e))};this._mouseWheelToDispose.push(a.addDisposableListener(this._listenOnDomNode,a.EventType.MOUSE_WHEEL,e,{passive:!1}))}}_onMouseWheel(e){if(e.browserEvent?.defaultPrevented)return;const t=S.INSTANCE;t.acceptStandardWheelEvent(e);let i=!1;if(e.deltaY||e.deltaX){let s=e.deltaY*this._options.mouseWheelScrollSensitivity,r=e.deltaX*this._options.mouseWheelScrollSensitivity;this._options.scrollPredominantAxis&&(this._options.scrollYToX&&r+s===0?r=s=0:Math.abs(s)>=Math.abs(r)?r=0:s=0),this._options.flipAxes&&([s,r]=[r,s]);const n=!g.isMacintosh&&e.browserEvent&&e.browserEvent.shiftKey;!this._options.scrollYToX&&!n||r||(r=s,s=0),e.browserEvent&&e.browserEvent.altKey&&(r*=this._options.fastScrollSensitivity,s*=this._options.fastScrollSensitivity);const o=this._scrollable.getFutureScrollPosition();let a={};if(s){const e=50*s,t=o.scrollTop-(e<0?Math.floor(e):Math.ceil(e));this._verticalScrollbar.writeScrollPosition(a,t)}if(r){const e=50*r,t=o.scrollLeft-(e<0?Math.floor(e):Math.ceil(e));this._horizontalScrollbar.writeScrollPosition(a,t)}a=this._scrollable.validateScrollPosition(a),(o.scrollLeft!==a.scrollLeft||o.scrollTop!==a.scrollTop)&&(this._options.mouseWheelSmoothScroll&&t.isPhysicalMouseWheel()?this._scrollable.setScrollPositionSmooth(a):this._scrollable.setScrollPositionNow(a),i=!0)}let s=i;!s&&this._options.alwaysConsumeMouseWheel&&(s=!0),!s&&this._options.consumeMouseWheelIfScrollbarIsNeeded&&(this._verticalScrollbar.isNeeded()||this._horizontalScrollbar.isNeeded())&&(s=!0),s&&(e.preventDefault(),e.stopPropagation())}_onDidScroll(e){this._shouldRender=this._horizontalScrollbar.onDidScroll(e)||this._shouldRender,this._shouldRender=this._verticalScrollbar.onDidScroll(e)||this._shouldRender,this._options.useShadows&&(this._shouldRender=!0),this._revealOnScroll&&this._reveal(),this._options.lazyRender||this._render()}renderNow(){if(!this._options.lazyRender)throw new Error("Please use `lazyRender` together with `renderNow`!");this._render()}_render(){if(this._shouldRender&&(this._shouldRender=!1,this._horizontalScrollbar.render(),this._verticalScrollbar.render(),this._options.useShadows)){const e=this._scrollable.getCurrentScrollPosition(),t=e.scrollTop>0,i=e.scrollLeft>0,s=i?" left":"",r=t?" top":"",n=i||t?" top-left-corner":"";this._leftShadowDomNode.setClassName(`shadow${s}`),this._topShadowDomNode.setClassName(`shadow${r}`),this._topLeftShadowDomNode.setClassName(`shadow${n}${r}${s}`)}}_onDragStart(){this._isDragging=!0,this._reveal()}_onDragEnd(){this._isDragging=!1,this._hide()}_onMouseLeave(e){this._mouseIsOver=!1,this._hide()}_onMouseOver(e){this._mouseIsOver=!0,this._reveal()}_reveal(){this._verticalScrollbar.beginReveal(),this._horizontalScrollbar.beginReveal(),this._scheduleHide()}_hide(){this._mouseIsOver||this._isDragging||(this._verticalScrollbar.beginHide(),this._horizontalScrollbar.beginHide())}_scheduleHide(){this._mouseIsOver||this._isDragging||this._hideTimeout.cancelAndSet((()=>this._hide()),500)}}t.AbstractScrollableElement=b,t.ScrollableElement=class extends b{constructor(e,t){(t=t||{}).mouseWheelSmoothScroll=!1;const i=new m.Scrollable({forceIntegerValues:!0,smoothScrollDuration:0,scheduleAtNextAnimationFrame:t=>a.scheduleAtNextAnimationFrame(a.getWindow(e),t)});super(e,t,i),this._register(i)}setScrollPosition(e){this._scrollable.setScrollPositionNow(e)}getScrollPosition(){return this._scrollable.getCurrentScrollPosition()}},t.SmoothScrollableElement=class extends b{constructor(e,t,i){super(e,t,i)}setScrollPosition(e){e.reuseAnimation?this._scrollable.setScrollPositionSmooth(e,e.reuseAnimation):this._scrollable.setScrollPositionNow(e)}getScrollPosition(){return this._scrollable.getCurrentScrollPosition()}},t.DomScrollableElement=class extends b{constructor(e,t){(t=t||{}).mouseWheelSmoothScroll=!1;const i=new m.Scrollable({forceIntegerValues:!1,smoothScrollDuration:0,scheduleAtNextAnimationFrame:t=>a.scheduleAtNextAnimationFrame(a.getWindow(e),t)});super(e,t,i),this._register(i),this._element=e,this._register(this.onScroll((e=>{e.scrollTopChanged&&(this._element.scrollTop=e.scrollTop),e.scrollLeftChanged&&(this._element.scrollLeft=e.scrollLeft)}))),this.scanDomNode()}setScrollPosition(e){this._scrollable.setScrollPositionNow(e)}getScrollPosition(){return this._scrollable.getCurrentScrollPosition()}scanDomNode(){this.setScrollDimensions({width:this._element.clientWidth,scrollWidth:this._element.scrollWidth,height:this._element.clientHeight,scrollHeight:this._element.scrollHeight}),this.setScrollPosition({scrollLeft:this._element.scrollLeft,scrollTop:this._element.scrollTop})}}},8974:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.ScrollbarArrow=t.ARROW_IMG_SIZE=void 0;const o=i(8328),a=i(8286),l=i(1758),h=n(i(7093));t.ARROW_IMG_SIZE=11;class c extends a.Widget{constructor(e){super(),this._onActivate=e.onActivate,this.bgDomNode=document.createElement("div"),this.bgDomNode.className="arrow-background",this.bgDomNode.style.position="absolute",this.bgDomNode.style.width=e.bgWidth+"px",this.bgDomNode.style.height=e.bgHeight+"px",void 0!==e.top&&(this.bgDomNode.style.top="0px"),void 0!==e.left&&(this.bgDomNode.style.left="0px"),void 0!==e.bottom&&(this.bgDomNode.style.bottom="0px"),void 0!==e.right&&(this.bgDomNode.style.right="0px"),this.domNode=document.createElement("div"),this.domNode.className=e.className,this.domNode.style.position="absolute",this.domNode.style.width=t.ARROW_IMG_SIZE+"px",this.domNode.style.height=t.ARROW_IMG_SIZE+"px",void 0!==e.top&&(this.domNode.style.top=e.top+"px"),void 0!==e.left&&(this.domNode.style.left=e.left+"px"),void 0!==e.bottom&&(this.domNode.style.bottom=e.bottom+"px"),void 0!==e.right&&(this.domNode.style.right=e.right+"px"),this._pointerMoveMonitor=this._register(new o.GlobalPointerMoveMonitor),this._register(h.addStandardDisposableListener(this.bgDomNode,h.EventType.POINTER_DOWN,(e=>this._arrowPointerDown(e)))),this._register(h.addStandardDisposableListener(this.domNode,h.EventType.POINTER_DOWN,(e=>this._arrowPointerDown(e)))),this._pointerdownRepeatTimer=this._register(new h.WindowIntervalTimer),this._pointerdownScheduleRepeatTimer=this._register(new l.TimeoutTimer)}_arrowPointerDown(e){e.target&&e.target instanceof Element&&(this._onActivate(),this._pointerdownRepeatTimer.cancel(),this._pointerdownScheduleRepeatTimer.cancelAndSet((()=>{this._pointerdownRepeatTimer.cancelAndSet((()=>this._onActivate()),1e3/24,h.getWindow(e))}),200),this._pointerMoveMonitor.startMonitoring(e.target,e.pointerId,e.buttons,(e=>{}),(()=>{this._pointerdownRepeatTimer.cancel(),this._pointerdownScheduleRepeatTimer.cancel()})),e.preventDefault())}}t.ScrollbarArrow=c},8245:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ScrollbarState=void 0;class i{constructor(e,t,i,s,r,n){this._scrollbarSize=Math.round(t),this._oppositeScrollbarSize=Math.round(i),this._arrowSize=Math.round(e),this._visibleSize=s,this._scrollSize=r,this._scrollPosition=n,this._computedAvailableSize=0,this._computedIsNeeded=!1,this._computedSliderSize=0,this._computedSliderRatio=0,this._computedSliderPosition=0,this._refreshComputedValues()}clone(){return new i(this._arrowSize,this._scrollbarSize,this._oppositeScrollbarSize,this._visibleSize,this._scrollSize,this._scrollPosition)}setVisibleSize(e){const t=Math.round(e);return this._visibleSize!==t&&(this._visibleSize=t,this._refreshComputedValues(),!0)}setScrollSize(e){const t=Math.round(e);return this._scrollSize!==t&&(this._scrollSize=t,this._refreshComputedValues(),!0)}setScrollPosition(e){const t=Math.round(e);return this._scrollPosition!==t&&(this._scrollPosition=t,this._refreshComputedValues(),!0)}setScrollbarSize(e){this._scrollbarSize=Math.round(e)}setOppositeScrollbarSize(e){this._oppositeScrollbarSize=Math.round(e)}static _computeValues(e,t,i,s,r){const n=Math.max(0,i-e),o=Math.max(0,n-2*t),a=s>0&&s>i;if(!a)return{computedAvailableSize:Math.round(n),computedIsNeeded:a,computedSliderSize:Math.round(o),computedSliderRatio:0,computedSliderPosition:0};const l=Math.round(Math.max(20,Math.floor(i*o/s))),h=(o-l)/(s-i),c=r*h;return{computedAvailableSize:Math.round(n),computedIsNeeded:a,computedSliderSize:Math.round(l),computedSliderRatio:h,computedSliderPosition:Math.round(c)}}_refreshComputedValues(){const e=i._computeValues(this._oppositeScrollbarSize,this._arrowSize,this._visibleSize,this._scrollSize,this._scrollPosition);this._computedAvailableSize=e.computedAvailableSize,this._computedIsNeeded=e.computedIsNeeded,this._computedSliderSize=e.computedSliderSize,this._computedSliderRatio=e.computedSliderRatio,this._computedSliderPosition=e.computedSliderPosition}getArrowSize(){return this._arrowSize}getScrollPosition(){return this._scrollPosition}getRectangleLargeSize(){return this._computedAvailableSize}getRectangleSmallSize(){return this._scrollbarSize}isNeeded(){return this._computedIsNeeded}getSliderSize(){return this._computedSliderSize}getSliderPosition(){return this._computedSliderPosition}getDesiredScrollPositionFromOffset(e){if(!this._computedIsNeeded)return 0;const t=e-this._arrowSize-this._computedSliderSize/2;return Math.round(t/this._computedSliderRatio)}getDesiredScrollPositionFromOffsetPaged(e){if(!this._computedIsNeeded)return 0;const t=e-this._arrowSize;let i=this._scrollPosition;return t{Object.defineProperty(t,"__esModule",{value:!0}),t.ScrollbarVisibilityController=void 0;const s=i(1758),r=i(7150),n=i(9881);class o extends r.Disposable{constructor(e,t,i){super(),this._visibility=e,this._visibleClassName=t,this._invisibleClassName=i,this._domNode=null,this._isVisible=!1,this._isNeeded=!1,this._rawShouldBeVisible=!1,this._shouldBeVisible=!1,this._revealTimer=this._register(new s.TimeoutTimer)}setVisibility(e){this._visibility!==e&&(this._visibility=e,this._updateShouldBeVisible())}setShouldBeVisible(e){this._rawShouldBeVisible=e,this._updateShouldBeVisible()}_applyVisibilitySetting(){return this._visibility!==n.ScrollbarVisibility.Hidden&&(this._visibility===n.ScrollbarVisibility.Visible||this._rawShouldBeVisible)}_updateShouldBeVisible(){const e=this._applyVisibilitySetting();this._shouldBeVisible!==e&&(this._shouldBeVisible=e,this.ensureVisibility())}setIsNeeded(e){this._isNeeded!==e&&(this._isNeeded=e,this.ensureVisibility())}setDomNode(e){this._domNode=e,this._domNode.setClassName(this._invisibleClassName),this.setShouldBeVisible(!1)}ensureVisibility(){this._isNeeded?this._shouldBeVisible?this._reveal():this._hide(!0):this._hide(!1)}_reveal(){this._isVisible||(this._isVisible=!0,this._revealTimer.setIfNotSet((()=>{this._domNode?.setClassName(this._visibleClassName)}),0))}_hide(e){this._revealTimer.cancel(),this._isVisible&&(this._isVisible=!1,this._domNode?.setClassName(this._invisibleClassName+(e?" fade":"")))}}t.ScrollbarVisibilityController=o},5473:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.VerticalScrollbar=void 0;const s=i(8801),r=i(8245),n=i(9881);class o extends s.AbstractScrollbar{constructor(e,t,i){const s=e.getScrollDimensions(),o=e.getCurrentScrollPosition();if(super({lazyRender:t.lazyRender,host:i,scrollbarState:new r.ScrollbarState(t.verticalHasArrows?t.arrowSize:0,t.vertical===n.ScrollbarVisibility.Hidden?0:t.verticalScrollbarSize,0,s.height,s.scrollHeight,o.scrollTop),visibility:t.vertical,extraScrollbarClassName:"vertical",scrollable:e,scrollByPage:t.scrollByPage}),t.verticalHasArrows)throw new Error("horizontalHasArrows is not supported in xterm.js");this._createSlider(0,Math.floor((t.verticalScrollbarSize-t.verticalSliderSize)/2),t.verticalSliderSize,void 0)}_updateSlider(e,t){this.slider.setHeight(e),this.slider.setTop(t)}_renderDomNode(e,t){this.domNode.setWidth(t),this.domNode.setHeight(e),this.domNode.setRight(0),this.domNode.setTop(0)}onDidScroll(e){return this._shouldRender=this._onElementScrollSize(e.scrollHeight)||this._shouldRender,this._shouldRender=this._onElementScrollPosition(e.scrollTop)||this._shouldRender,this._shouldRender=this._onElementSize(e.height)||this._shouldRender,this._shouldRender}_pointerDownRelativePosition(e,t){return t}_sliderPointerPosition(e){return e.pageY}_sliderOrthogonalPointerPosition(e){return e.pageX}_updateScrollbarSize(e){this.slider.setWidth(e)}writeScrollPosition(e,t){e.scrollTop=t}updateOptions(e){this.updateScrollbarSize(e.vertical===n.ScrollbarVisibility.Hidden?0:e.verticalScrollbarSize),this._scrollbarState.setOppositeScrollbarSize(0),this._visibilityController.setVisibility(e.vertical),this._scrollByPage=e.scrollByPage}}t.VerticalScrollbar=o},8286:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.Widget=void 0;const o=n(i(7093)),a=i(5394),l=i(5964),h=i(8594),c=i(7150);class d extends c.Disposable{onclick(e,t){this._register(o.addDisposableListener(e,o.EventType.CLICK,(i=>t(new l.StandardMouseEvent(o.getWindow(e),i)))))}onmousedown(e,t){this._register(o.addDisposableListener(e,o.EventType.MOUSE_DOWN,(i=>t(new l.StandardMouseEvent(o.getWindow(e),i)))))}onmouseover(e,t){this._register(o.addDisposableListener(e,o.EventType.MOUSE_OVER,(i=>t(new l.StandardMouseEvent(o.getWindow(e),i)))))}onmouseleave(e,t){this._register(o.addDisposableListener(e,o.EventType.MOUSE_LEAVE,(i=>t(new l.StandardMouseEvent(o.getWindow(e),i)))))}onkeydown(e,t){this._register(o.addDisposableListener(e,o.EventType.KEY_DOWN,(e=>t(new a.StandardKeyboardEvent(e)))))}onkeyup(e,t){this._register(o.addDisposableListener(e,o.EventType.KEY_UP,(e=>t(new a.StandardKeyboardEvent(e)))))}oninput(e,t){this._register(o.addDisposableListener(e,o.EventType.INPUT,t))}onblur(e,t){this._register(o.addDisposableListener(e,o.EventType.BLUR,t))}onfocus(e,t){this._register(o.addDisposableListener(e,o.EventType.FOCUS,t))}onchange(e,t){this._register(o.addDisposableListener(e,o.EventType.CHANGE,t))}ignoreGesture(e){return h.Gesture.ignoreTarget(e)}}t.Widget=d},4693:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.mainWindow=void 0,t.ensureCodeWindow=function(e,t){},t.mainWindow="object"==typeof window?window:globalThis},3058:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Permutation=t.CallbackIterable=t.ArrayQueue=t.booleanComparator=t.numberComparator=t.CompareResult=void 0,t.tail=function(e,t=0){return e[e.length-(1+t)]},t.tail2=function(e){if(0===e.length)throw new Error("Invalid tail call");return[e.slice(0,e.length-1),e[e.length-1]]},t.equals=function(e,t,i=(e,t)=>e===t){if(e===t)return!0;if(!e||!t)return!1;if(e.length!==t.length)return!1;for(let s=0,r=e.length;si(e[s],t)))},t.binarySearch2=n,t.quickSelect=function e(t,i,s){if((t|=0)>=i.length)throw new TypeError("invalid index");const r=i[Math.floor(i.length*Math.random())],n=[],o=[],a=[];for(const e of i){const t=s(e,r);t<0?n.push(e):t>0?o.push(e):a.push(e)}return t{(async()=>{const o=e.length,l=e.slice(0,i).sort(t);for(let h=i,c=Math.min(i+r,o);hi&&await new Promise((e=>setTimeout(e))),n&&n.isCancellationRequested)throw new s.CancellationError;a(e,t,l,h,c)}return l})().then(o,l)}))},t.coalesce=function(e){return e.filter((e=>!!e))},t.coalesceInPlace=function(e){let t=0;for(let i=0;i0},t.distinct=function(e,t=e=>e){const i=new Set;return e.filter((e=>{const s=t(e);return!i.has(s)&&(i.add(s),!0)}))},t.uniqueFilter=function(e){const t=new Set;return i=>{const s=e(i);return!t.has(s)&&(t.add(s),!0)}},t.firstOrDefault=function(e,t){return e.length>0?e[0]:t},t.lastOrDefault=function(e,t){return e.length>0?e[e.length-1]:t},t.commonPrefixLength=function(e,t,i=(e,t)=>e===t){let s=0;for(let r=0,n=Math.min(e.length,t.length);rt;e--)s.push(e);return s},t.index=function(e,t,i){return e.reduce(((e,s)=>(e[t(s)]=i?i(s):s,e)),Object.create(null))},t.insert=function(e,t){return e.push(t),()=>l(e,t)},t.remove=l,t.arrayInsert=function(e,t,i){const s=e.slice(0,t),r=e.slice(t);return s.concat(i,r)},t.shuffle=function(e,t){let i;if("number"==typeof t){let e=t;i=()=>{const t=179426549*Math.sin(e++);return t-Math.floor(t)}}else i=Math.random;for(let t=e.length-1;t>0;t-=1){const s=Math.floor(i()*(t+1)),r=e[t];e[t]=e[s],e[s]=r}},t.pushToStart=function(e,t){const i=e.indexOf(t);i>-1&&(e.splice(i,1),e.unshift(t))},t.pushToEnd=function(e,t){const i=e.indexOf(t);i>-1&&(e.splice(i,1),e.push(t))},t.pushMany=function(e,t){for(const i of t)e.push(i)},t.mapArrayOrNot=function(e,t){return Array.isArray(e)?e.map(t):t(e)},t.asArray=function(e){return Array.isArray(e)?e:[e]},t.getRandomElement=function(e){return e[Math.floor(Math.random()*e.length)]},t.insertInto=h,t.splice=function(e,t,i,s){const r=c(e,t);let n=e.splice(r,i);return void 0===n&&(n=[]),h(e,r,s),n},t.compareBy=function(e,t){return(i,s)=>t(e(i),e(s))},t.tieBreakComparators=function(...e){return(t,i)=>{for(const s of e){const e=s(t,i);if(!d.isNeitherLessOrGreaterThan(e))return e}return d.neitherLessOrGreaterThan}},t.reverseOrder=function(e){return(t,i)=>-e(t,i)};const s=i(9807),r=i(8297);function n(e,t){let i=0,s=e-1;for(;i<=s;){const e=(i+s)/2|0,r=t(e);if(r<0)i=e+1;else{if(!(r>0))return e;s=e-1}}return-(i+1)}function o(e,t,i){const s=[];function r(e,t,i){if(0===t&&0===i.length)return;const r=s[s.length-1];r&&r.start+r.deleteCount===e?(r.deleteCount+=t,r.toInsert.push(...i)):s.push({start:e,deleteCount:t,toInsert:i})}let n=0,o=0;for(;;){if(n===e.length){r(n,0,t.slice(o));break}if(o===t.length){r(n,e.length-n,[]);break}const s=e[n],a=t[o],l=i(s,a);0===l?(n+=1,o+=1):l<0?(r(n,1,[]),n+=1):l>0&&(r(n,0,[a]),o+=1)}return s}function a(e,t,i,s,n){for(const o=i.length;st(n,e)<0));i.splice(e,0,n)}}}function l(e,t){const i=e.indexOf(t);if(i>-1)return e.splice(i,1),t}function h(e,t,i){const s=c(e,t),r=e.length,n=i.length;e.length=r+n;for(let t=r-1;t>=s;t--)e[t+n]=e[t];for(let t=0;t0},e.isNeitherLessOrGreaterThan=function(e){return 0===e},e.greaterThan=1,e.lessThan=-1,e.neitherLessOrGreaterThan=0}(d||(t.CompareResult=d={})),t.numberComparator=(e,t)=>e-t,t.booleanComparator=(e,i)=>(0,t.numberComparator)(e?1:0,i?1:0),t.ArrayQueue=class{constructor(e){this.items=e,this.firstIdx=0,this.lastIdx=this.items.length-1}get length(){return this.lastIdx-this.firstIdx+1}takeWhile(e){let t=this.firstIdx;for(;t=0&&e(this.items[t]);)t--;const i=t===this.lastIdx?null:this.items.slice(t+1,this.lastIdx+1);return this.lastIdx=t,i}peek(){if(0!==this.length)return this.items[this.firstIdx]}peekLast(){if(0!==this.length)return this.items[this.lastIdx]}dequeue(){const e=this.items[this.firstIdx];return this.firstIdx++,e}removeLast(){const e=this.items[this.lastIdx];return this.lastIdx--,e}takeCount(e){const t=this.items.slice(this.firstIdx,this.firstIdx+e);return this.firstIdx+=e,t}};class u{static{this.empty=new u((e=>{}))}constructor(e){this.iterate=e}forEach(e){this.iterate((t=>(e(t),!0)))}toArray(){const e=[];return this.iterate((t=>(e.push(t),!0))),e}filter(e){return new u((t=>this.iterate((i=>!e(i)||t(i)))))}map(e){return new u((t=>this.iterate((i=>t(e(i))))))}some(e){let t=!1;return this.iterate((i=>(t=e(i),!t))),t}findFirst(e){let t;return this.iterate((i=>!e(i)||(t=i,!1))),t}findLast(e){let t;return this.iterate((i=>(e(i)&&(t=i),!0))),t}findLastMaxBy(e){let t,i=!0;return this.iterate((s=>((i||d.isGreaterThan(e(s,t)))&&(i=!1,t=s),!0))),t}}t.CallbackIterable=u;class _{constructor(e){this._indexMap=e}static createSortPermutation(e,t){const i=Array.from(e.keys()).sort(((i,s)=>t(e[i],e[s])));return new _(i)}apply(e){return e.map(((t,i)=>e[this._indexMap[i]]))}inverse(){const e=this._indexMap.slice();for(let t=0;t{function i(e,t,i=e.length-1){for(let s=i;s>=0;s--)if(t(e[s]))return s;return-1}function s(e,t,i=0,s=e.length){let r=i,n=s;for(;r=0&&(i=r)}return i},t.findFirstMin=function(e,t){return o(e,((e,i)=>-t(e,i)))},t.findMaxIdx=function(e,t){if(0===e.length)return-1;let i=0;for(let s=1;s0&&(i=s);return i},t.mapFindFirst=function(e,t){for(const i of e){const e=t(i);if(void 0!==e)return e}};class n{static{this.assertInvariants=!1}constructor(e){this._array=e,this._findLastMonotonousLastIdx=0}findLastMonotonous(e){if(n.assertInvariants){if(this._prevFindLastPredicate)for(const t of this._array)if(this._prevFindLastPredicate(t)&&!e(t))throw new Error("MonotonousArray: current predicate must be weaker than (or equal to) the previous predicate.");this._prevFindLastPredicate=e}const t=s(this._array,e,this._findLastMonotonousLastIdx);return this._findLastMonotonousLastIdx=t+1,-1===t?void 0:this._array[t]}}function o(e,t){if(0===e.length)return;let i=e[0];for(let s=1;s0&&(i=r)}return i}t.MonotonousArray=n},1758:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.AsyncIterableSource=t.CancelableAsyncIterableObject=t.AsyncIterableObject=t.LazyStatefulPromise=t.StatefulPromise=t.Promises=t.DeferredPromise=t.IntervalCounter=t.TaskSequentializer=t.GlobalIdleValue=t.AbstractIdleValue=t._runWhenIdle=t.runWhenGlobalIdle=t.ThrottledWorker=t.RunOnceWorker=t.ProcessTimeRunOnceScheduler=t.RunOnceScheduler=t.IntervalTimer=t.TimeoutTimer=t.LimitedQueue=t.Queue=t.Limiter=t.AutoOpenBarrier=t.Barrier=t.ThrottledDelayer=t.Delayer=t.SequencerByKey=t.Sequencer=t.Throttler=void 0,t.isThenable=c,t.createCancelablePromise=d,t.raceCancellation=function(e,t,i){return new Promise(((s,r)=>{const n=t.onCancellationRequested((()=>{n.dispose(),s(i)}));e.then(s,r).finally((()=>n.dispose()))}))},t.raceCancellationError=function(e,t){return new Promise(((i,s)=>{const n=t.onCancellationRequested((()=>{n.dispose(),s(new r.CancellationError)}));e.then(i,s).finally((()=>n.dispose()))}))},t.raceCancellablePromises=async function(e){let t=-1;const i=e.map(((e,i)=>e.then((e=>(t=i,e)))));try{return await Promise.race(i)}finally{e.forEach(((e,i)=>{i!==t&&e.cancel()}))}},t.raceTimeout=function(e,t,i){let s;const r=setTimeout((()=>{s?.(void 0),i?.()}),t);return Promise.race([e.finally((()=>clearTimeout(r))),new Promise((e=>s=e))])},t.asPromise=function(e){return new Promise(((t,i)=>{const s=e();c(s)?s.then(t,i):t(s)}))},t.promiseWithResolvers=u,t.timeout=g,t.disposableTimeout=function(e,t=0,i){const s=setTimeout((()=>{e(),i&&r.dispose()}),t),r=(0,o.toDisposable)((()=>{clearTimeout(s),i?.deleteAndLeak(r)}));return i?.add(r),r},t.sequence=function(e){const t=[];let i=0;const s=e.length;return Promise.resolve(null).then((function r(n){null!=n&&t.push(n);const o=i!!e,i=null){let s=0;const r=e.length,n=()=>{if(s>=r)return Promise.resolve(i);const o=e[s++];return Promise.resolve(o()).then((e=>t(e)?Promise.resolve(e):n()))};return n()},t.firstParallel=function(e,t=e=>!!e,i=null){if(0===e.length)return Promise.resolve(i);let s=e.length;const r=()=>{s=-1;for(const t of e)t.cancel?.()};return new Promise(((n,o)=>{for(const a of e)a.then((e=>{--s>=0&&t(e)?(r(),n(e)):0===s&&n(i)})).catch((e=>{--s>=0&&(r(),o(e))}))}))},t.retry=async function(e,t,i){let s;for(let r=0;r{const s=t.token.onCancellationRequested((()=>{s.dispose(),t.dispose(),e.reject(new r.CancellationError)}));try{for await(const s of i){if(t.token.isCancellationRequested)return;e.emitOne(s)}s.dispose(),t.dispose()}catch(i){s.dispose(),t.dispose(),e.reject(i)}}))};const s=i(8447),r=i(9807),n=i(802),o=i(7150),a=i(8163),l=i(5015),h=i(626);function c(e){return!!e&&"function"==typeof e.then}function d(e){const t=new s.CancellationTokenSource,i=e(t.token),n=new Promise(((e,s)=>{const n=t.token.onCancellationRequested((()=>{n.dispose(),s(new r.CancellationError)}));Promise.resolve(i).then((i=>{n.dispose(),t.dispose(),e(i)}),(e=>{n.dispose(),t.dispose(),s(e)}))}));return new class{cancel(){t.cancel(),t.dispose()}then(e,t){return n.then(e,t)}catch(e){return this.then(void 0,e)}finally(e){return n.finally(e)}}}function u(){let e,t;return{promise:new Promise(((i,s)=>{e=i,t=s})),resolve:e,reject:t}}class _{constructor(){this.isDisposed=!1,this.activePromise=null,this.queuedPromise=null,this.queuedPromiseFactory=null}queue(e){if(this.isDisposed)return Promise.reject(new Error("Throttler is disposed"));if(this.activePromise){if(this.queuedPromiseFactory=e,!this.queuedPromise){const e=()=>{if(this.queuedPromise=null,this.isDisposed)return;const e=this.queue(this.queuedPromiseFactory);return this.queuedPromiseFactory=null,e};this.queuedPromise=new Promise((t=>{this.activePromise.then(e,e).then(t)}))}return new Promise(((e,t)=>{this.queuedPromise.then(e,t)}))}return this.activePromise=e(),new Promise(((e,t)=>{this.activePromise.then((t=>{this.activePromise=null,e(t)}),(e=>{this.activePromise=null,t(e)}))}))}dispose(){this.isDisposed=!0}}t.Throttler=_,t.Sequencer=class{constructor(){this.current=Promise.resolve(null)}queue(e){return this.current=this.current.then((()=>e()),(()=>e()))}},t.SequencerByKey=class{constructor(){this.promiseMap=new Map}queue(e,t){const i=(this.promiseMap.get(e)??Promise.resolve()).catch((()=>{})).then(t).finally((()=>{this.promiseMap.get(e)===i&&this.promiseMap.delete(e)}));return this.promiseMap.set(e,i),i}};class f{constructor(e){this.defaultDelay=e,this.deferred=null,this.completionPromise=null,this.doResolve=null,this.doReject=null,this.task=null}trigger(e,t=this.defaultDelay){this.task=e,this.cancelTimeout(),this.completionPromise||(this.completionPromise=new Promise(((e,t)=>{this.doResolve=e,this.doReject=t})).then((()=>{if(this.completionPromise=null,this.doResolve=null,this.task){const e=this.task;return this.task=null,e()}})));const i=()=>{this.deferred=null,this.doResolve?.(null)};return this.deferred=t===l.MicrotaskDelay?(e=>{let t=!0;return queueMicrotask((()=>{t&&(t=!1,e())})),{isTriggered:()=>t,dispose:()=>{t=!1}}})(i):((e,t)=>{let i=!0;const s=setTimeout((()=>{i=!1,t()}),e);return{isTriggered:()=>i,dispose:()=>{clearTimeout(s),i=!1}}})(t,i),this.completionPromise}isTriggered(){return!!this.deferred?.isTriggered()}cancel(){this.cancelTimeout(),this.completionPromise&&(this.doReject?.(new r.CancellationError),this.completionPromise=null)}cancelTimeout(){this.deferred?.dispose(),this.deferred=null}dispose(){this.cancel()}}t.Delayer=f,t.ThrottledDelayer=class{constructor(e){this.delayer=new f(e),this.throttler=new _}trigger(e,t){return this.delayer.trigger((()=>this.throttler.queue(e)),t)}isTriggered(){return this.delayer.isTriggered()}cancel(){this.delayer.cancel()}dispose(){this.delayer.dispose(),this.throttler.dispose()}};class p{constructor(){this._isOpen=!1,this._promise=new Promise(((e,t)=>{this._completePromise=e}))}isOpen(){return this._isOpen}open(){this._isOpen=!0,this._completePromise(!0)}wait(){return this._promise}}function g(e,t){return t?new Promise(((i,s)=>{const n=setTimeout((()=>{o.dispose(),i()}),e),o=t.onCancellationRequested((()=>{clearTimeout(n),o.dispose(),s(new r.CancellationError)}))})):d((t=>g(e,t)))}t.Barrier=p,t.AutoOpenBarrier=class extends p{constructor(e){super(),this._timeout=setTimeout((()=>this.open()),e)}open(){clearTimeout(this._timeout),super.open()}};class m{constructor(e){this._size=0,this._isDisposed=!1,this.maxDegreeOfParalellism=e,this.outstandingPromises=[],this.runningPromises=0,this._onDrained=new n.Emitter}whenIdle(){return this.size>0?n.Event.toPromise(this.onDrained):Promise.resolve()}get onDrained(){return this._onDrained.event}get size(){return this._size}queue(e){if(this._isDisposed)throw new Error("Object has been disposed");return this._size++,new Promise(((t,i)=>{this.outstandingPromises.push({factory:e,c:t,e:i}),this.consume()}))}consume(){for(;this.outstandingPromises.length&&this.runningPromisesthis.consumed()),(()=>this.consumed()))}}consumed(){this._isDisposed||(this.runningPromises--,0==--this._size&&this._onDrained.fire(),this.outstandingPromises.length>0&&this.consume())}clear(){if(this._isDisposed)throw new Error("Object has been disposed");this.outstandingPromises.length=0,this._size=this.runningPromises}dispose(){this._isDisposed=!0,this.outstandingPromises.length=0,this._size=0,this._onDrained.dispose()}}t.Limiter=m,t.Queue=class extends m{constructor(){super(1)}},t.LimitedQueue=class{constructor(){this.sequentializer=new C,this.tasks=0}queue(e){return this.sequentializer.isRunning()?this.sequentializer.queue((()=>this.sequentializer.run(this.tasks++,e()))):this.sequentializer.run(this.tasks++,e())}},t.TimeoutTimer=class{constructor(e,t){this._isDisposed=!1,this._token=-1,"function"==typeof e&&"number"==typeof t&&this.setIfNotSet(e,t)}dispose(){this.cancel(),this._isDisposed=!0}cancel(){-1!==this._token&&(clearTimeout(this._token),this._token=-1)}cancelAndSet(e,t){if(this._isDisposed)throw new r.BugIndicatingError("Calling 'cancelAndSet' on a disposed TimeoutTimer");this.cancel(),this._token=setTimeout((()=>{this._token=-1,e()}),t)}setIfNotSet(e,t){if(this._isDisposed)throw new r.BugIndicatingError("Calling 'setIfNotSet' on a disposed TimeoutTimer");-1===this._token&&(this._token=setTimeout((()=>{this._token=-1,e()}),t))}},t.IntervalTimer=class{constructor(){this.disposable=void 0,this.isDisposed=!1}cancel(){this.disposable?.dispose(),this.disposable=void 0}cancelAndSet(e,t,i=globalThis){if(this.isDisposed)throw new r.BugIndicatingError("Calling 'cancelAndSet' on a disposed IntervalTimer");this.cancel();const s=i.setInterval((()=>{e()}),t);this.disposable=(0,o.toDisposable)((()=>{i.clearInterval(s),this.disposable=void 0}))}dispose(){this.cancel(),this.isDisposed=!0}};class v{constructor(e,t){this.timeoutToken=-1,this.runner=e,this.timeout=t,this.timeoutHandler=this.onTimeout.bind(this)}dispose(){this.cancel(),this.runner=null}cancel(){this.isScheduled()&&(clearTimeout(this.timeoutToken),this.timeoutToken=-1)}schedule(e=this.timeout){this.cancel(),this.timeoutToken=setTimeout(this.timeoutHandler,e)}get delay(){return this.timeout}set delay(e){this.timeout=e}isScheduled(){return-1!==this.timeoutToken}flush(){this.isScheduled()&&(this.cancel(),this.doRun())}onTimeout(){this.timeoutToken=-1,this.runner&&this.doRun()}doRun(){this.runner?.()}}t.RunOnceScheduler=v,t.ProcessTimeRunOnceScheduler=class{constructor(e,t){t%1e3!=0&&console.warn(`ProcessTimeRunOnceScheduler resolution is 1s, ${t}ms is not a multiple of 1000ms.`),this.runner=e,this.timeout=t,this.counter=0,this.intervalToken=-1,this.intervalHandler=this.onInterval.bind(this)}dispose(){this.cancel(),this.runner=null}cancel(){this.isScheduled()&&(clearInterval(this.intervalToken),this.intervalToken=-1)}schedule(e=this.timeout){e%1e3!=0&&console.warn(`ProcessTimeRunOnceScheduler resolution is 1s, ${e}ms is not a multiple of 1000ms.`),this.cancel(),this.counter=Math.ceil(e/1e3),this.intervalToken=setInterval(this.intervalHandler,1e3)}isScheduled(){return-1!==this.intervalToken}onInterval(){this.counter--,this.counter>0||(clearInterval(this.intervalToken),this.intervalToken=-1,this.runner?.())}},t.RunOnceWorker=class extends v{constructor(e,t){super(e,t),this.units=[]}work(e){this.units.push(e),this.isScheduled()||this.schedule()}doRun(){const e=this.units;this.units=[],this.runner?.(e)}dispose(){this.units=[],super.dispose()}};class S extends o.Disposable{constructor(e,t){super(),this.options=e,this.handler=t,this.pendingWork=[],this.throttler=this._register(new o.MutableDisposable),this.disposed=!1}get pending(){return this.pendingWork.length}work(e){if(this.disposed)return!1;if("number"==typeof this.options.maxBufferedWork)if(this.throttler.value){if(this.pending+e.length>this.options.maxBufferedWork)return!1}else if(this.pending+e.length-this.options.maxWorkChunkSize>this.options.maxBufferedWork)return!1;for(const t of e)this.pendingWork.push(t);return this.throttler.value||this.doWork(),!0}doWork(){this.handler(this.pendingWork.splice(0,this.options.maxWorkChunkSize)),this.pendingWork.length>0&&(this.throttler.value=new v((()=>{this.throttler.clear(),this.doWork()}),this.options.throttleDelay),this.throttler.value.schedule())}dispose(){super.dispose(),this.disposed=!0}}t.ThrottledWorker=S,"function"!=typeof globalThis.requestIdleCallback||"function"!=typeof globalThis.cancelIdleCallback?t._runWhenIdle=(e,t)=>{(0,a.setTimeout0)((()=>{if(i)return;const e=Date.now()+15,s={didTimeout:!0,timeRemaining:()=>Math.max(0,e-Date.now())};t(Object.freeze(s))}));let i=!1;return{dispose(){i||(i=!0)}}}:t._runWhenIdle=(e,t,i)=>{const s=e.requestIdleCallback(t,"number"==typeof i?{timeout:i}:void 0);let r=!1;return{dispose(){r||(r=!0,e.cancelIdleCallback(s))}}},t.runWhenGlobalIdle=e=>(0,t._runWhenIdle)(globalThis,e);class b{constructor(e,i){this._didRun=!1,this._executor=()=>{try{this._value=i()}catch(e){this._error=e}finally{this._didRun=!0}},this._handle=(0,t._runWhenIdle)(e,(()=>this._executor()))}dispose(){this._handle.dispose()}get value(){if(this._didRun||(this._handle.dispose(),this._executor()),this._error)throw this._error;return this._value}get isInitialized(){return this._didRun}}t.AbstractIdleValue=b,t.GlobalIdleValue=class extends b{constructor(e){super(globalThis,e)}};class C{isRunning(e){return"number"==typeof e?this._running?.taskId===e:!!this._running}get running(){return this._running?.promise}cancelRunning(){this._running?.cancel()}run(e,t,i){return this._running={taskId:e,cancel:()=>i?.(),promise:t},t.then((()=>this.doneRunning(e)),(()=>this.doneRunning(e))),t}doneRunning(e){this._running&&e===this._running.taskId&&(this._running=void 0,this.runQueued())}runQueued(){if(this._queued){const e=this._queued;this._queued=void 0,e.run().then(e.promiseResolve,e.promiseReject)}}queue(e){if(this._queued)this._queued.run=e;else{const{promise:t,resolve:i,reject:s}=u();this._queued={run:e,promise:t,promiseResolve:i,promiseReject:s}}return this._queued.promise}hasQueued(){return!!this._queued}async join(){return this._queued?.promise??this._running?.promise}}var y,w,E;t.TaskSequentializer=C,t.IntervalCounter=class{constructor(e,t=()=>Date.now()){this.interval=e,this.nowFn=t,this.lastIncrementTime=0,this.value=0}increment(){const e=this.nowFn();return e-this.lastIncrementTime>this.interval&&(this.lastIncrementTime=e,this.value=0),this.value++,this.value}},function(e){e[e.Resolved=0]="Resolved",e[e.Rejected=1]="Rejected"}(y||(y={}));class D{get isRejected(){return this.outcome?.outcome===y.Rejected}get isResolved(){return this.outcome?.outcome===y.Resolved}get isSettled(){return!!this.outcome}get value(){return this.outcome?.outcome===y.Resolved?this.outcome?.value:void 0}constructor(){this.p=new Promise(((e,t)=>{this.completeCallback=e,this.errorCallback=t}))}complete(e){return new Promise((t=>{this.completeCallback(e),this.outcome={outcome:y.Resolved,value:e},t()}))}error(e){return new Promise((t=>{this.errorCallback(e),this.outcome={outcome:y.Rejected,value:e},t()}))}cancel(){return this.error(new r.CancellationError)}}t.DeferredPromise=D,function(e){e.settled=async function(e){let t;const i=await Promise.all(e.map((e=>e.then((e=>e),(e=>{t||(t=e)})))));if(void 0!==t)throw t;return i},e.withAsyncBody=function(e){return new Promise((async(t,i)=>{try{await e(t,i)}catch(e){i(e)}}))}}(w||(t.Promises=w={}));class L{get value(){return this._value}get error(){return this._error}get isResolved(){return this._isResolved}constructor(e){this._value=void 0,this._error=void 0,this._isResolved=!1,this.promise=e.then((e=>(this._value=e,this._isResolved=!0,e)),(e=>{throw this._error=e,this._isResolved=!0,e}))}requireValue(){if(!this._isResolved)throw new r.BugIndicatingError("Promise is not resolved yet");if(this._error)throw this._error;return this._value}}t.StatefulPromise=L,t.LazyStatefulPromise=class{constructor(e){this._compute=e,this._promise=new h.Lazy((()=>new L(this._compute())))}requireValue(){return this._promise.value.requireValue()}getPromise(){return this._promise.value.promise}get currentValue(){return this._promise.rawValue?.value}},function(e){e[e.Initial=0]="Initial",e[e.DoneOK=1]="DoneOK",e[e.DoneError=2]="DoneError"}(E||(E={}));class R{static fromArray(e){return new R((t=>{t.emitMany(e)}))}static fromPromise(e){return new R((async t=>{t.emitMany(await e)}))}static fromPromises(e){return new R((async t=>{await Promise.all(e.map((async e=>t.emitOne(await e))))}))}static merge(e){return new R((async t=>{await Promise.all(e.map((async e=>{for await(const i of e)t.emitOne(i)})))}))}static{this.EMPTY=R.fromArray([])}constructor(e,t){this._state=E.Initial,this._results=[],this._error=null,this._onReturn=t,this._onStateChanged=new n.Emitter,queueMicrotask((async()=>{const t={emitOne:e=>this.emitOne(e),emitMany:e=>this.emitMany(e),reject:e=>this.reject(e)};try{await Promise.resolve(e(t)),this.resolve()}catch(e){this.reject(e)}finally{t.emitOne=void 0,t.emitMany=void 0,t.reject=void 0}}))}[Symbol.asyncIterator](){let e=0;return{next:async()=>{for(;;){if(this._state===E.DoneError)throw this._error;if(e(this._onReturn?.(),{done:!0,value:void 0})}}static map(e,t){return new R((async i=>{for await(const s of e)i.emitOne(t(s))}))}map(e){return R.map(this,e)}static filter(e,t){return new R((async i=>{for await(const s of e)t(s)&&i.emitOne(s)}))}filter(e){return R.filter(this,e)}static coalesce(e){return R.filter(e,(e=>!!e))}coalesce(){return R.coalesce(this)}static async toPromise(e){const t=[];for await(const i of e)t.push(i);return t}toPromise(){return R.toPromise(this)}emitOne(e){this._state===E.Initial&&(this._results.push(e),this._onStateChanged.fire())}emitMany(e){this._state===E.Initial&&(this._results=this._results.concat(e),this._onStateChanged.fire())}resolve(){this._state===E.Initial&&(this._state=E.DoneOK,this._onStateChanged.fire())}reject(e){this._state===E.Initial&&(this._state=E.DoneError,this._error=e,this._onStateChanged.fire())}}t.AsyncIterableObject=R;class A extends R{constructor(e,t){super(t),this._source=e}cancel(){this._source.cancel()}}t.CancelableAsyncIterableObject=A,t.AsyncIterableSource=class{constructor(e){let t,i;this._deferred=new D,this._asyncIterable=new R((e=>{if(!t)return i&&e.emitMany(i),this._errorFn=t=>e.reject(t),this._emitFn=t=>e.emitOne(t),this._deferred.p;e.reject(t)}),e),this._emitFn=e=>{i||(i=[]),i.push(e)},this._errorFn=e=>{t||(t=e)}}get asyncIterable(){return this._asyncIterable}resolve(){this._deferred.complete()}reject(e){this._errorFn(e),this._deferred.complete()}emitOne(e){this._emitFn(e)}}},8447:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CancellationTokenSource=t.CancellationToken=void 0,t.cancelOnDispose=function(e){const t=new a;return e.add({dispose(){t.cancel()}}),t.token};const s=i(802),r=Object.freeze((function(e,t){const i=setTimeout(e.bind(t),0);return{dispose(){clearTimeout(i)}}}));var n;!function(e){e.isCancellationToken=function(t){return t===e.None||t===e.Cancelled||t instanceof o||!(!t||"object"!=typeof t)&&"boolean"==typeof t.isCancellationRequested&&"function"==typeof t.onCancellationRequested},e.None=Object.freeze({isCancellationRequested:!1,onCancellationRequested:s.Event.None}),e.Cancelled=Object.freeze({isCancellationRequested:!0,onCancellationRequested:r})}(n||(t.CancellationToken=n={}));class o{constructor(){this._isCancelled=!1,this._emitter=null}cancel(){this._isCancelled||(this._isCancelled=!0,this._emitter&&(this._emitter.fire(void 0),this.dispose()))}get isCancellationRequested(){return this._isCancelled}get onCancellationRequested(){return this._isCancelled?r:(this._emitter||(this._emitter=new s.Emitter),this._emitter.event)}dispose(){this._emitter&&(this._emitter.dispose(),this._emitter=null)}}class a{constructor(e){this._token=void 0,this._parentListener=void 0,this._parentListener=e&&e.onCancellationRequested(this.cancel,this)}get token(){return this._token||(this._token=new o),this._token}cancel(){this._token?this._token instanceof o&&this._token.cancel():this._token=n.Cancelled}dispose(e=!1){e&&this.cancel(),this._parentListener?.dispose(),this._token?this._token instanceof o&&this._token.dispose():this._token=n.None}}t.CancellationTokenSource=a},4869:(e,t)=>{var i;Object.defineProperty(t,"__esModule",{value:!0}),t.CharCode=void 0,function(e){e[e.Null=0]="Null",e[e.Backspace=8]="Backspace",e[e.Tab=9]="Tab",e[e.LineFeed=10]="LineFeed",e[e.CarriageReturn=13]="CarriageReturn",e[e.Space=32]="Space",e[e.ExclamationMark=33]="ExclamationMark",e[e.DoubleQuote=34]="DoubleQuote",e[e.Hash=35]="Hash",e[e.DollarSign=36]="DollarSign",e[e.PercentSign=37]="PercentSign",e[e.Ampersand=38]="Ampersand",e[e.SingleQuote=39]="SingleQuote",e[e.OpenParen=40]="OpenParen",e[e.CloseParen=41]="CloseParen",e[e.Asterisk=42]="Asterisk",e[e.Plus=43]="Plus",e[e.Comma=44]="Comma",e[e.Dash=45]="Dash",e[e.Period=46]="Period",e[e.Slash=47]="Slash",e[e.Digit0=48]="Digit0",e[e.Digit1=49]="Digit1",e[e.Digit2=50]="Digit2",e[e.Digit3=51]="Digit3",e[e.Digit4=52]="Digit4",e[e.Digit5=53]="Digit5",e[e.Digit6=54]="Digit6",e[e.Digit7=55]="Digit7",e[e.Digit8=56]="Digit8",e[e.Digit9=57]="Digit9",e[e.Colon=58]="Colon",e[e.Semicolon=59]="Semicolon",e[e.LessThan=60]="LessThan",e[e.Equals=61]="Equals",e[e.GreaterThan=62]="GreaterThan",e[e.QuestionMark=63]="QuestionMark",e[e.AtSign=64]="AtSign",e[e.A=65]="A",e[e.B=66]="B",e[e.C=67]="C",e[e.D=68]="D",e[e.E=69]="E",e[e.F=70]="F",e[e.G=71]="G",e[e.H=72]="H",e[e.I=73]="I",e[e.J=74]="J",e[e.K=75]="K",e[e.L=76]="L",e[e.M=77]="M",e[e.N=78]="N",e[e.O=79]="O",e[e.P=80]="P",e[e.Q=81]="Q",e[e.R=82]="R",e[e.S=83]="S",e[e.T=84]="T",e[e.U=85]="U",e[e.V=86]="V",e[e.W=87]="W",e[e.X=88]="X",e[e.Y=89]="Y",e[e.Z=90]="Z",e[e.OpenSquareBracket=91]="OpenSquareBracket",e[e.Backslash=92]="Backslash",e[e.CloseSquareBracket=93]="CloseSquareBracket",e[e.Caret=94]="Caret",e[e.Underline=95]="Underline",e[e.BackTick=96]="BackTick",e[e.a=97]="a",e[e.b=98]="b",e[e.c=99]="c",e[e.d=100]="d",e[e.e=101]="e",e[e.f=102]="f",e[e.g=103]="g",e[e.h=104]="h",e[e.i=105]="i",e[e.j=106]="j",e[e.k=107]="k",e[e.l=108]="l",e[e.m=109]="m",e[e.n=110]="n",e[e.o=111]="o",e[e.p=112]="p",e[e.q=113]="q",e[e.r=114]="r",e[e.s=115]="s",e[e.t=116]="t",e[e.u=117]="u",e[e.v=118]="v",e[e.w=119]="w",e[e.x=120]="x",e[e.y=121]="y",e[e.z=122]="z",e[e.OpenCurlyBrace=123]="OpenCurlyBrace",e[e.Pipe=124]="Pipe",e[e.CloseCurlyBrace=125]="CloseCurlyBrace",e[e.Tilde=126]="Tilde",e[e.NoBreakSpace=160]="NoBreakSpace",e[e.U_Combining_Grave_Accent=768]="U_Combining_Grave_Accent",e[e.U_Combining_Acute_Accent=769]="U_Combining_Acute_Accent",e[e.U_Combining_Circumflex_Accent=770]="U_Combining_Circumflex_Accent",e[e.U_Combining_Tilde=771]="U_Combining_Tilde",e[e.U_Combining_Macron=772]="U_Combining_Macron",e[e.U_Combining_Overline=773]="U_Combining_Overline",e[e.U_Combining_Breve=774]="U_Combining_Breve",e[e.U_Combining_Dot_Above=775]="U_Combining_Dot_Above",e[e.U_Combining_Diaeresis=776]="U_Combining_Diaeresis",e[e.U_Combining_Hook_Above=777]="U_Combining_Hook_Above",e[e.U_Combining_Ring_Above=778]="U_Combining_Ring_Above",e[e.U_Combining_Double_Acute_Accent=779]="U_Combining_Double_Acute_Accent",e[e.U_Combining_Caron=780]="U_Combining_Caron",e[e.U_Combining_Vertical_Line_Above=781]="U_Combining_Vertical_Line_Above",e[e.U_Combining_Double_Vertical_Line_Above=782]="U_Combining_Double_Vertical_Line_Above",e[e.U_Combining_Double_Grave_Accent=783]="U_Combining_Double_Grave_Accent",e[e.U_Combining_Candrabindu=784]="U_Combining_Candrabindu",e[e.U_Combining_Inverted_Breve=785]="U_Combining_Inverted_Breve",e[e.U_Combining_Turned_Comma_Above=786]="U_Combining_Turned_Comma_Above",e[e.U_Combining_Comma_Above=787]="U_Combining_Comma_Above",e[e.U_Combining_Reversed_Comma_Above=788]="U_Combining_Reversed_Comma_Above",e[e.U_Combining_Comma_Above_Right=789]="U_Combining_Comma_Above_Right",e[e.U_Combining_Grave_Accent_Below=790]="U_Combining_Grave_Accent_Below",e[e.U_Combining_Acute_Accent_Below=791]="U_Combining_Acute_Accent_Below",e[e.U_Combining_Left_Tack_Below=792]="U_Combining_Left_Tack_Below",e[e.U_Combining_Right_Tack_Below=793]="U_Combining_Right_Tack_Below",e[e.U_Combining_Left_Angle_Above=794]="U_Combining_Left_Angle_Above",e[e.U_Combining_Horn=795]="U_Combining_Horn",e[e.U_Combining_Left_Half_Ring_Below=796]="U_Combining_Left_Half_Ring_Below",e[e.U_Combining_Up_Tack_Below=797]="U_Combining_Up_Tack_Below",e[e.U_Combining_Down_Tack_Below=798]="U_Combining_Down_Tack_Below",e[e.U_Combining_Plus_Sign_Below=799]="U_Combining_Plus_Sign_Below",e[e.U_Combining_Minus_Sign_Below=800]="U_Combining_Minus_Sign_Below",e[e.U_Combining_Palatalized_Hook_Below=801]="U_Combining_Palatalized_Hook_Below",e[e.U_Combining_Retroflex_Hook_Below=802]="U_Combining_Retroflex_Hook_Below",e[e.U_Combining_Dot_Below=803]="U_Combining_Dot_Below",e[e.U_Combining_Diaeresis_Below=804]="U_Combining_Diaeresis_Below",e[e.U_Combining_Ring_Below=805]="U_Combining_Ring_Below",e[e.U_Combining_Comma_Below=806]="U_Combining_Comma_Below",e[e.U_Combining_Cedilla=807]="U_Combining_Cedilla",e[e.U_Combining_Ogonek=808]="U_Combining_Ogonek",e[e.U_Combining_Vertical_Line_Below=809]="U_Combining_Vertical_Line_Below",e[e.U_Combining_Bridge_Below=810]="U_Combining_Bridge_Below",e[e.U_Combining_Inverted_Double_Arch_Below=811]="U_Combining_Inverted_Double_Arch_Below",e[e.U_Combining_Caron_Below=812]="U_Combining_Caron_Below",e[e.U_Combining_Circumflex_Accent_Below=813]="U_Combining_Circumflex_Accent_Below",e[e.U_Combining_Breve_Below=814]="U_Combining_Breve_Below",e[e.U_Combining_Inverted_Breve_Below=815]="U_Combining_Inverted_Breve_Below",e[e.U_Combining_Tilde_Below=816]="U_Combining_Tilde_Below",e[e.U_Combining_Macron_Below=817]="U_Combining_Macron_Below",e[e.U_Combining_Low_Line=818]="U_Combining_Low_Line",e[e.U_Combining_Double_Low_Line=819]="U_Combining_Double_Low_Line",e[e.U_Combining_Tilde_Overlay=820]="U_Combining_Tilde_Overlay",e[e.U_Combining_Short_Stroke_Overlay=821]="U_Combining_Short_Stroke_Overlay",e[e.U_Combining_Long_Stroke_Overlay=822]="U_Combining_Long_Stroke_Overlay",e[e.U_Combining_Short_Solidus_Overlay=823]="U_Combining_Short_Solidus_Overlay",e[e.U_Combining_Long_Solidus_Overlay=824]="U_Combining_Long_Solidus_Overlay",e[e.U_Combining_Right_Half_Ring_Below=825]="U_Combining_Right_Half_Ring_Below",e[e.U_Combining_Inverted_Bridge_Below=826]="U_Combining_Inverted_Bridge_Below",e[e.U_Combining_Square_Below=827]="U_Combining_Square_Below",e[e.U_Combining_Seagull_Below=828]="U_Combining_Seagull_Below",e[e.U_Combining_X_Above=829]="U_Combining_X_Above",e[e.U_Combining_Vertical_Tilde=830]="U_Combining_Vertical_Tilde",e[e.U_Combining_Double_Overline=831]="U_Combining_Double_Overline",e[e.U_Combining_Grave_Tone_Mark=832]="U_Combining_Grave_Tone_Mark",e[e.U_Combining_Acute_Tone_Mark=833]="U_Combining_Acute_Tone_Mark",e[e.U_Combining_Greek_Perispomeni=834]="U_Combining_Greek_Perispomeni",e[e.U_Combining_Greek_Koronis=835]="U_Combining_Greek_Koronis",e[e.U_Combining_Greek_Dialytika_Tonos=836]="U_Combining_Greek_Dialytika_Tonos",e[e.U_Combining_Greek_Ypogegrammeni=837]="U_Combining_Greek_Ypogegrammeni",e[e.U_Combining_Bridge_Above=838]="U_Combining_Bridge_Above",e[e.U_Combining_Equals_Sign_Below=839]="U_Combining_Equals_Sign_Below",e[e.U_Combining_Double_Vertical_Line_Below=840]="U_Combining_Double_Vertical_Line_Below",e[e.U_Combining_Left_Angle_Below=841]="U_Combining_Left_Angle_Below",e[e.U_Combining_Not_Tilde_Above=842]="U_Combining_Not_Tilde_Above",e[e.U_Combining_Homothetic_Above=843]="U_Combining_Homothetic_Above",e[e.U_Combining_Almost_Equal_To_Above=844]="U_Combining_Almost_Equal_To_Above",e[e.U_Combining_Left_Right_Arrow_Below=845]="U_Combining_Left_Right_Arrow_Below",e[e.U_Combining_Upwards_Arrow_Below=846]="U_Combining_Upwards_Arrow_Below",e[e.U_Combining_Grapheme_Joiner=847]="U_Combining_Grapheme_Joiner",e[e.U_Combining_Right_Arrowhead_Above=848]="U_Combining_Right_Arrowhead_Above",e[e.U_Combining_Left_Half_Ring_Above=849]="U_Combining_Left_Half_Ring_Above",e[e.U_Combining_Fermata=850]="U_Combining_Fermata",e[e.U_Combining_X_Below=851]="U_Combining_X_Below",e[e.U_Combining_Left_Arrowhead_Below=852]="U_Combining_Left_Arrowhead_Below",e[e.U_Combining_Right_Arrowhead_Below=853]="U_Combining_Right_Arrowhead_Below",e[e.U_Combining_Right_Arrowhead_And_Up_Arrowhead_Below=854]="U_Combining_Right_Arrowhead_And_Up_Arrowhead_Below",e[e.U_Combining_Right_Half_Ring_Above=855]="U_Combining_Right_Half_Ring_Above",e[e.U_Combining_Dot_Above_Right=856]="U_Combining_Dot_Above_Right",e[e.U_Combining_Asterisk_Below=857]="U_Combining_Asterisk_Below",e[e.U_Combining_Double_Ring_Below=858]="U_Combining_Double_Ring_Below",e[e.U_Combining_Zigzag_Above=859]="U_Combining_Zigzag_Above",e[e.U_Combining_Double_Breve_Below=860]="U_Combining_Double_Breve_Below",e[e.U_Combining_Double_Breve=861]="U_Combining_Double_Breve",e[e.U_Combining_Double_Macron=862]="U_Combining_Double_Macron",e[e.U_Combining_Double_Macron_Below=863]="U_Combining_Double_Macron_Below",e[e.U_Combining_Double_Tilde=864]="U_Combining_Double_Tilde",e[e.U_Combining_Double_Inverted_Breve=865]="U_Combining_Double_Inverted_Breve",e[e.U_Combining_Double_Rightwards_Arrow_Below=866]="U_Combining_Double_Rightwards_Arrow_Below",e[e.U_Combining_Latin_Small_Letter_A=867]="U_Combining_Latin_Small_Letter_A",e[e.U_Combining_Latin_Small_Letter_E=868]="U_Combining_Latin_Small_Letter_E",e[e.U_Combining_Latin_Small_Letter_I=869]="U_Combining_Latin_Small_Letter_I",e[e.U_Combining_Latin_Small_Letter_O=870]="U_Combining_Latin_Small_Letter_O",e[e.U_Combining_Latin_Small_Letter_U=871]="U_Combining_Latin_Small_Letter_U",e[e.U_Combining_Latin_Small_Letter_C=872]="U_Combining_Latin_Small_Letter_C",e[e.U_Combining_Latin_Small_Letter_D=873]="U_Combining_Latin_Small_Letter_D",e[e.U_Combining_Latin_Small_Letter_H=874]="U_Combining_Latin_Small_Letter_H",e[e.U_Combining_Latin_Small_Letter_M=875]="U_Combining_Latin_Small_Letter_M",e[e.U_Combining_Latin_Small_Letter_R=876]="U_Combining_Latin_Small_Letter_R",e[e.U_Combining_Latin_Small_Letter_T=877]="U_Combining_Latin_Small_Letter_T",e[e.U_Combining_Latin_Small_Letter_V=878]="U_Combining_Latin_Small_Letter_V",e[e.U_Combining_Latin_Small_Letter_X=879]="U_Combining_Latin_Small_Letter_X",e[e.LINE_SEPARATOR=8232]="LINE_SEPARATOR",e[e.PARAGRAPH_SEPARATOR=8233]="PARAGRAPH_SEPARATOR",e[e.NEXT_LINE=133]="NEXT_LINE",e[e.U_CIRCUMFLEX=94]="U_CIRCUMFLEX",e[e.U_GRAVE_ACCENT=96]="U_GRAVE_ACCENT",e[e.U_DIAERESIS=168]="U_DIAERESIS",e[e.U_MACRON=175]="U_MACRON",e[e.U_ACUTE_ACCENT=180]="U_ACUTE_ACCENT",e[e.U_CEDILLA=184]="U_CEDILLA",e[e.U_MODIFIER_LETTER_LEFT_ARROWHEAD=706]="U_MODIFIER_LETTER_LEFT_ARROWHEAD",e[e.U_MODIFIER_LETTER_RIGHT_ARROWHEAD=707]="U_MODIFIER_LETTER_RIGHT_ARROWHEAD",e[e.U_MODIFIER_LETTER_UP_ARROWHEAD=708]="U_MODIFIER_LETTER_UP_ARROWHEAD",e[e.U_MODIFIER_LETTER_DOWN_ARROWHEAD=709]="U_MODIFIER_LETTER_DOWN_ARROWHEAD",e[e.U_MODIFIER_LETTER_CENTRED_RIGHT_HALF_RING=722]="U_MODIFIER_LETTER_CENTRED_RIGHT_HALF_RING",e[e.U_MODIFIER_LETTER_CENTRED_LEFT_HALF_RING=723]="U_MODIFIER_LETTER_CENTRED_LEFT_HALF_RING",e[e.U_MODIFIER_LETTER_UP_TACK=724]="U_MODIFIER_LETTER_UP_TACK",e[e.U_MODIFIER_LETTER_DOWN_TACK=725]="U_MODIFIER_LETTER_DOWN_TACK",e[e.U_MODIFIER_LETTER_PLUS_SIGN=726]="U_MODIFIER_LETTER_PLUS_SIGN",e[e.U_MODIFIER_LETTER_MINUS_SIGN=727]="U_MODIFIER_LETTER_MINUS_SIGN",e[e.U_BREVE=728]="U_BREVE",e[e.U_DOT_ABOVE=729]="U_DOT_ABOVE",e[e.U_RING_ABOVE=730]="U_RING_ABOVE",e[e.U_OGONEK=731]="U_OGONEK",e[e.U_SMALL_TILDE=732]="U_SMALL_TILDE",e[e.U_DOUBLE_ACUTE_ACCENT=733]="U_DOUBLE_ACUTE_ACCENT",e[e.U_MODIFIER_LETTER_RHOTIC_HOOK=734]="U_MODIFIER_LETTER_RHOTIC_HOOK",e[e.U_MODIFIER_LETTER_CROSS_ACCENT=735]="U_MODIFIER_LETTER_CROSS_ACCENT",e[e.U_MODIFIER_LETTER_EXTRA_HIGH_TONE_BAR=741]="U_MODIFIER_LETTER_EXTRA_HIGH_TONE_BAR",e[e.U_MODIFIER_LETTER_HIGH_TONE_BAR=742]="U_MODIFIER_LETTER_HIGH_TONE_BAR",e[e.U_MODIFIER_LETTER_MID_TONE_BAR=743]="U_MODIFIER_LETTER_MID_TONE_BAR",e[e.U_MODIFIER_LETTER_LOW_TONE_BAR=744]="U_MODIFIER_LETTER_LOW_TONE_BAR",e[e.U_MODIFIER_LETTER_EXTRA_LOW_TONE_BAR=745]="U_MODIFIER_LETTER_EXTRA_LOW_TONE_BAR",e[e.U_MODIFIER_LETTER_YIN_DEPARTING_TONE_MARK=746]="U_MODIFIER_LETTER_YIN_DEPARTING_TONE_MARK",e[e.U_MODIFIER_LETTER_YANG_DEPARTING_TONE_MARK=747]="U_MODIFIER_LETTER_YANG_DEPARTING_TONE_MARK",e[e.U_MODIFIER_LETTER_UNASPIRATED=749]="U_MODIFIER_LETTER_UNASPIRATED",e[e.U_MODIFIER_LETTER_LOW_DOWN_ARROWHEAD=751]="U_MODIFIER_LETTER_LOW_DOWN_ARROWHEAD",e[e.U_MODIFIER_LETTER_LOW_UP_ARROWHEAD=752]="U_MODIFIER_LETTER_LOW_UP_ARROWHEAD",e[e.U_MODIFIER_LETTER_LOW_LEFT_ARROWHEAD=753]="U_MODIFIER_LETTER_LOW_LEFT_ARROWHEAD",e[e.U_MODIFIER_LETTER_LOW_RIGHT_ARROWHEAD=754]="U_MODIFIER_LETTER_LOW_RIGHT_ARROWHEAD",e[e.U_MODIFIER_LETTER_LOW_RING=755]="U_MODIFIER_LETTER_LOW_RING",e[e.U_MODIFIER_LETTER_MIDDLE_GRAVE_ACCENT=756]="U_MODIFIER_LETTER_MIDDLE_GRAVE_ACCENT",e[e.U_MODIFIER_LETTER_MIDDLE_DOUBLE_GRAVE_ACCENT=757]="U_MODIFIER_LETTER_MIDDLE_DOUBLE_GRAVE_ACCENT",e[e.U_MODIFIER_LETTER_MIDDLE_DOUBLE_ACUTE_ACCENT=758]="U_MODIFIER_LETTER_MIDDLE_DOUBLE_ACUTE_ACCENT",e[e.U_MODIFIER_LETTER_LOW_TILDE=759]="U_MODIFIER_LETTER_LOW_TILDE",e[e.U_MODIFIER_LETTER_RAISED_COLON=760]="U_MODIFIER_LETTER_RAISED_COLON",e[e.U_MODIFIER_LETTER_BEGIN_HIGH_TONE=761]="U_MODIFIER_LETTER_BEGIN_HIGH_TONE",e[e.U_MODIFIER_LETTER_END_HIGH_TONE=762]="U_MODIFIER_LETTER_END_HIGH_TONE",e[e.U_MODIFIER_LETTER_BEGIN_LOW_TONE=763]="U_MODIFIER_LETTER_BEGIN_LOW_TONE",e[e.U_MODIFIER_LETTER_END_LOW_TONE=764]="U_MODIFIER_LETTER_END_LOW_TONE",e[e.U_MODIFIER_LETTER_SHELF=765]="U_MODIFIER_LETTER_SHELF",e[e.U_MODIFIER_LETTER_OPEN_SHELF=766]="U_MODIFIER_LETTER_OPEN_SHELF",e[e.U_MODIFIER_LETTER_LOW_LEFT_ARROW=767]="U_MODIFIER_LETTER_LOW_LEFT_ARROW",e[e.U_GREEK_LOWER_NUMERAL_SIGN=885]="U_GREEK_LOWER_NUMERAL_SIGN",e[e.U_GREEK_TONOS=900]="U_GREEK_TONOS",e[e.U_GREEK_DIALYTIKA_TONOS=901]="U_GREEK_DIALYTIKA_TONOS",e[e.U_GREEK_KORONIS=8125]="U_GREEK_KORONIS",e[e.U_GREEK_PSILI=8127]="U_GREEK_PSILI",e[e.U_GREEK_PERISPOMENI=8128]="U_GREEK_PERISPOMENI",e[e.U_GREEK_DIALYTIKA_AND_PERISPOMENI=8129]="U_GREEK_DIALYTIKA_AND_PERISPOMENI",e[e.U_GREEK_PSILI_AND_VARIA=8141]="U_GREEK_PSILI_AND_VARIA",e[e.U_GREEK_PSILI_AND_OXIA=8142]="U_GREEK_PSILI_AND_OXIA",e[e.U_GREEK_PSILI_AND_PERISPOMENI=8143]="U_GREEK_PSILI_AND_PERISPOMENI",e[e.U_GREEK_DASIA_AND_VARIA=8157]="U_GREEK_DASIA_AND_VARIA",e[e.U_GREEK_DASIA_AND_OXIA=8158]="U_GREEK_DASIA_AND_OXIA",e[e.U_GREEK_DASIA_AND_PERISPOMENI=8159]="U_GREEK_DASIA_AND_PERISPOMENI",e[e.U_GREEK_DIALYTIKA_AND_VARIA=8173]="U_GREEK_DIALYTIKA_AND_VARIA",e[e.U_GREEK_DIALYTIKA_AND_OXIA=8174]="U_GREEK_DIALYTIKA_AND_OXIA",e[e.U_GREEK_VARIA=8175]="U_GREEK_VARIA",e[e.U_GREEK_OXIA=8189]="U_GREEK_OXIA",e[e.U_GREEK_DASIA=8190]="U_GREEK_DASIA",e[e.U_IDEOGRAPHIC_FULL_STOP=12290]="U_IDEOGRAPHIC_FULL_STOP",e[e.U_LEFT_CORNER_BRACKET=12300]="U_LEFT_CORNER_BRACKET",e[e.U_RIGHT_CORNER_BRACKET=12301]="U_RIGHT_CORNER_BRACKET",e[e.U_LEFT_BLACK_LENTICULAR_BRACKET=12304]="U_LEFT_BLACK_LENTICULAR_BRACKET",e[e.U_RIGHT_BLACK_LENTICULAR_BRACKET=12305]="U_RIGHT_BLACK_LENTICULAR_BRACKET",e[e.U_OVERLINE=8254]="U_OVERLINE",e[e.UTF8_BOM=65279]="UTF8_BOM",e[e.U_FULLWIDTH_SEMICOLON=65307]="U_FULLWIDTH_SEMICOLON",e[e.U_FULLWIDTH_COMMA=65292]="U_FULLWIDTH_COMMA"}(i||(t.CharCode=i={}))},9087:(e,t)=>{var i;Object.defineProperty(t,"__esModule",{value:!0}),t.SetWithKey=void 0,t.groupBy=function(e,t){const i=Object.create(null);for(const s of e){const e=t(s);let r=i[e];r||(r=i[e]=[]),r.push(s)}return i},t.diffSets=function(e,t){const i=[],s=[];for(const s of e)t.has(s)||i.push(s);for(const i of t)e.has(i)||s.push(i);return{removed:i,added:s}},t.diffMaps=function(e,t){const i=[],s=[];for(const[s,r]of e)t.has(s)||i.push(r);for(const[i,r]of t)e.has(i)||s.push(r);return{removed:i,added:s}},t.intersection=function(e,t){const i=new Set;for(const s of t)e.has(s)&&i.add(s);return i};class s{static{i=Symbol.toStringTag}constructor(e,t){this.toKey=t,this._map=new Map,this[i]="SetWithKey";for(const t of e)this.add(t)}get size(){return this._map.size}add(e){const t=this.toKey(e);return this._map.set(t,e),this}delete(e){return this._map.delete(this.toKey(e))}has(e){return this._map.has(this.toKey(e))}*entries(){for(const e of this._map.values())yield[e,e]}keys(){return this.values()}*values(){for(const e of this._map.values())yield e}clear(){this._map.clear()}forEach(e,t){this._map.forEach((i=>e.call(t,i,i,this)))}[Symbol.iterator](){return this.values()}}t.SetWithKey=s},4838:(e,t)=>{function i(e){return(t,i,s)=>{let r=null,n=null;if("function"==typeof s.value?(r="value",n=s.value):"function"==typeof s.get&&(r="get",n=s.get),!n)throw new Error("not supported");s[r]=e(n,i)}}Object.defineProperty(t,"__esModule",{value:!0}),t.memoize=function(e,t,i){let s=null,r=null;if("function"==typeof i.value?(s="value",r=i.value,0!==r.length&&console.warn("Memoize should only be used in functions with zero parameters")):"function"==typeof i.get&&(s="get",r=i.get),!r)throw new Error("not supported");const n=`$memoize$${t}`;i[s]=function(...e){return this.hasOwnProperty(n)||Object.defineProperty(this,n,{configurable:!1,enumerable:!1,writable:!1,value:r.apply(this,e)}),this[n]}},t.debounce=function(e,t,s){return i(((i,r)=>{const n=`$debounce$${r}`,o=`$debounce$result$${r}`;return function(...r){this[o]||(this[o]=s?s():void 0),clearTimeout(this[n]),t&&(this[o]=t(this[o],...r),r=[this[o]]),this[n]=setTimeout((()=>{i.apply(this,r),this[o]=s?s():void 0}),e)}}))},t.throttle=function(e,t,s){return i(((i,r)=>{const n=`$throttle$timer$${r}`,o=`$throttle$result$${r}`,a=`$throttle$lastRun$${r}`,l=`$throttle$pending$${r}`;return function(...r){if(this[o]||(this[o]=s?s():void 0),null!==this[a]&&void 0!==this[a]||(this[a]=-Number.MAX_VALUE),t&&(this[o]=t(this[o],...r)),this[l])return;const h=this[a]+e;h<=Date.now()?(this[a]=Date.now(),i.apply(this,[this[o]]),this[o]=s?s():void 0):(this[l]=!0,this[n]=setTimeout((()=>{this[l]=!1,this[a]=Date.now(),i.apply(this,[this[o]]),this[o]=s?s():void 0}),h-Date.now()))}}))}},9807:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BugIndicatingError=t.ErrorNoTelemetry=t.ExpectedError=t.NotSupportedError=t.NotImplementedError=t.ReadonlyError=t.CancellationError=t.errorHandler=t.ErrorHandler=void 0,t.setUnexpectedErrorHandler=function(e){t.errorHandler.setUnexpectedErrorHandler(e)},t.isSigPipeError=function(e){if(!e||"object"!=typeof e)return!1;const t=e;return"EPIPE"===t.code&&"WRITE"===t.syscall?.toUpperCase()},t.onUnexpectedError=function(e){r(e)||t.errorHandler.onUnexpectedError(e)},t.onUnexpectedExternalError=function(e){r(e)||t.errorHandler.onUnexpectedExternalError(e)},t.transformErrorForSerialization=function(e){if(e instanceof Error){const{name:t,message:i}=e;return{$isError:!0,name:t,message:i,stack:e.stacktrace||e.stack,noTelemetry:c.isErrorNoTelemetry(e)}}return e},t.transformErrorFromSerialization=function(e){let t;return e.noTelemetry?t=new c:(t=new Error,t.name=e.name),t.message=e.message,t.stack=e.stack,t},t.isCancellationError=r,t.canceled=function(){const e=new Error(s);return e.name=e.message,e},t.illegalArgument=function(e){return e?new Error(`Illegal argument: ${e}`):new Error("Illegal argument")},t.illegalState=function(e){return e?new Error(`Illegal state: ${e}`):new Error("Illegal state")},t.getErrorMessage=function(e){return e?e.message?e.message:e.stack?e.stack.split("\n")[0]:String(e):"Error"};class i{constructor(){this.listeners=[],this.unexpectedErrorHandler=function(e){setTimeout((()=>{if(e.stack){if(c.isErrorNoTelemetry(e))throw new c(e.message+"\n\n"+e.stack);throw new Error(e.message+"\n\n"+e.stack)}throw e}),0)}}addListener(e){return this.listeners.push(e),()=>{this._removeListener(e)}}emit(e){this.listeners.forEach((t=>{t(e)}))}_removeListener(e){this.listeners.splice(this.listeners.indexOf(e),1)}setUnexpectedErrorHandler(e){this.unexpectedErrorHandler=e}getUnexpectedErrorHandler(){return this.unexpectedErrorHandler}onUnexpectedError(e){this.unexpectedErrorHandler(e),this.emit(e)}onUnexpectedExternalError(e){this.unexpectedErrorHandler(e)}}t.ErrorHandler=i,t.errorHandler=new i;const s="Canceled";function r(e){return e instanceof n||e instanceof Error&&e.name===s&&e.message===s}class n extends Error{constructor(){super(s),this.name=this.message}}t.CancellationError=n;class o extends TypeError{constructor(e){super(e?`${e} is read-only and cannot be changed`:"Cannot change read-only property")}}t.ReadonlyError=o;class a extends Error{constructor(e){super("NotImplemented"),e&&(this.message=e)}}t.NotImplementedError=a;class l extends Error{constructor(e){super("NotSupported"),e&&(this.message=e)}}t.NotSupportedError=l;class h extends Error{constructor(){super(...arguments),this.isExpected=!0}}t.ExpectedError=h;class c extends Error{constructor(e){super(e),this.name="CodeExpectedError"}static fromError(e){if(e instanceof c)return e;const t=new c;return t.message=e.message,t.stack=e.stack,t}static isErrorNoTelemetry(e){return"CodeExpectedError"===e.name}}t.ErrorNoTelemetry=c;class d extends Error{constructor(e){super(e||"An unexpected bug occurred."),Object.setPrototypeOf(this,d.prototype)}}t.BugIndicatingError=d},802:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ValueWithChangeEvent=t.Relay=t.EventBufferer=t.DynamicListEventMultiplexer=t.EventMultiplexer=t.MicrotaskEmitter=t.DebounceEmitter=t.PauseableEmitter=t.AsyncEmitter=t.createEventDeliveryQueue=t.Emitter=t.ListenerRefusalError=t.ListenerLeakError=t.EventProfiling=t.Event=void 0,t.setGlobalLeakWarningThreshold=function(e){const t=c;return c=e,{dispose(){c=t}}};const s=i(9807),r=i(8841),n=i(7150),o=i(6317),a=i(9725);var l;!function(e){function t(e){return(t,i=null,s)=>{let r,n=!1;return r=e((e=>{if(!n)return r?r.dispose():n=!0,t.call(i,e)}),null,s),n&&r.dispose(),r}}function i(e,t,i){return r(((i,s=null,r)=>e((e=>i.call(s,t(e))),null,r)),i)}function s(e,t,i){return r(((i,s=null,r)=>e((e=>t(e)&&i.call(s,e)),null,r)),i)}function r(e,t){let i;const s=new m({onWillAddFirstListener(){i=e(s.fire,s)},onDidRemoveLastListener(){i?.dispose()}});return t?.add(s),s.event}function o(e,t,i=100,s=!1,r=!1,n,o){let a,l,h,c,d=0;const u=new m({leakWarningThreshold:n,onWillAddFirstListener(){a=e((e=>{d++,l=t(l,e),s&&!h&&(u.fire(l),l=void 0),c=()=>{const e=l;l=void 0,h=void 0,(!s||d>1)&&u.fire(e),d=0},"number"==typeof i?(clearTimeout(h),h=setTimeout(c,i)):void 0===h&&(h=0,queueMicrotask(c))}))},onWillRemoveListener(){r&&d>0&&c?.()},onDidRemoveLastListener(){c=void 0,a.dispose()}});return o?.add(u),u.event}e.None=()=>n.Disposable.None,e.defer=function(e,t){return o(e,(()=>{}),0,void 0,!0,void 0,t)},e.once=t,e.map=i,e.forEach=function(e,t,i){return r(((i,s=null,r)=>e((e=>{t(e),i.call(s,e)}),null,r)),i)},e.filter=s,e.signal=function(e){return e},e.any=function(...e){return(t,i=null,s)=>{return r=(0,n.combinedDisposable)(...e.map((e=>e((e=>t.call(i,e)))))),(o=s)instanceof Array?o.push(r):o&&o.add(r),r;var r,o}},e.reduce=function(e,t,s,r){let n=s;return i(e,(e=>(n=t(n,e),n)),r)},e.debounce=o,e.accumulate=function(t,i=0,s){return e.debounce(t,((e,t)=>e?(e.push(t),e):[t]),i,void 0,!0,void 0,s)},e.latch=function(e,t=(e,t)=>e===t,i){let r,n=!0;return s(e,(e=>{const i=n||!t(e,r);return n=!1,r=e,i}),i)},e.split=function(t,i,s){return[e.filter(t,i,s),e.filter(t,(e=>!i(e)),s)]},e.buffer=function(e,t=!1,i=[],s){let r=i.slice(),n=e((e=>{r?r.push(e):a.fire(e)}));s&&s.add(n);const o=()=>{r?.forEach((e=>a.fire(e))),r=null},a=new m({onWillAddFirstListener(){n||(n=e((e=>a.fire(e))),s&&s.add(n))},onDidAddFirstListener(){r&&(t?setTimeout(o):o())},onDidRemoveLastListener(){n&&n.dispose(),n=null}});return s&&s.add(a),a.event},e.chain=function(e,t){return(i,s,r)=>{const n=t(new l);return e((function(e){const t=n.evaluate(e);t!==a&&i.call(s,t)}),void 0,r)}};const a=Symbol("HaltChainable");class l{constructor(){this.steps=[]}map(e){return this.steps.push(e),this}forEach(e){return this.steps.push((t=>(e(t),t))),this}filter(e){return this.steps.push((t=>e(t)?t:a)),this}reduce(e,t){let i=t;return this.steps.push((t=>(i=e(i,t),i))),this}latch(e=(e,t)=>e===t){let t,i=!0;return this.steps.push((s=>{const r=i||!e(s,t);return i=!1,t=s,r?s:a})),this}evaluate(e){for(const t of this.steps)if((e=t(e))===a)break;return e}}e.fromNodeEventEmitter=function(e,t,i=e=>e){const s=(...e)=>r.fire(i(...e)),r=new m({onWillAddFirstListener:()=>e.on(t,s),onDidRemoveLastListener:()=>e.removeListener(t,s)});return r.event},e.fromDOMEventEmitter=function(e,t,i=e=>e){const s=(...e)=>r.fire(i(...e)),r=new m({onWillAddFirstListener:()=>e.addEventListener(t,s),onDidRemoveLastListener:()=>e.removeEventListener(t,s)});return r.event},e.toPromise=function(e){return new Promise((i=>t(e)(i)))},e.fromPromise=function(e){const t=new m;return e.then((e=>{t.fire(e)}),(()=>{t.fire(void 0)})).finally((()=>{t.dispose()})),t.event},e.forward=function(e,t){return e((e=>t.fire(e)))},e.runAndSubscribe=function(e,t,i){return t(i),e((e=>t(e)))};class h{constructor(e,t){this._observable=e,this._counter=0,this._hasChanged=!1;const i={onWillAddFirstListener:()=>{e.addObserver(this)},onDidRemoveLastListener:()=>{e.removeObserver(this)}};this.emitter=new m(i),t&&t.add(this.emitter)}beginUpdate(e){this._counter++}handlePossibleChange(e){}handleChange(e,t){this._hasChanged=!0}endUpdate(e){this._counter--,0===this._counter&&(this._observable.reportChanges(),this._hasChanged&&(this._hasChanged=!1,this.emitter.fire(this._observable.get())))}}e.fromObservable=function(e,t){return new h(e,t).emitter.event},e.fromObservableLight=function(e){return(t,i,s)=>{let r=0,o=!1;const a={beginUpdate(){r++},endUpdate(){r--,0===r&&(e.reportChanges(),o&&(o=!1,t.call(i)))},handlePossibleChange(){},handleChange(){o=!0}};e.addObserver(a),e.reportChanges();const l={dispose(){e.removeObserver(a)}};return s instanceof n.DisposableStore?s.add(l):Array.isArray(s)&&s.push(l),l}}}(l||(t.Event=l={}));class h{static{this.all=new Set}static{this._idPool=0}constructor(e){this.listenerCount=0,this.invocationCount=0,this.elapsedOverall=0,this.durations=[],this.name=`${e}_${h._idPool++}`,h.all.add(this)}start(e){this._stopWatch=new a.StopWatch,this.listenerCount=e}stop(){if(this._stopWatch){const e=this._stopWatch.elapsed();this.durations.push(e),this.elapsedOverall+=e,this.invocationCount+=1,this._stopWatch=void 0}}}t.EventProfiling=h;let c=-1;class d{static{this._idPool=1}constructor(e,t,i=(d._idPool++).toString(16).padStart(3,"0")){this._errorHandler=e,this.threshold=t,this.name=i,this._warnCountdown=0}dispose(){this._stacks?.clear()}check(e,t){const i=this.threshold;if(i<=0||t{const t=this._stacks.get(e.value)||0;this._stacks.set(e.value,t-1)}}getMostFrequentStack(){if(!this._stacks)return;let e,t=0;for(const[i,s]of this._stacks)(!e||t0||this._options?.leakWarningThreshold?new d(e?.onListenerError??s.onUnexpectedError,this._options?.leakWarningThreshold??c):void 0,this._perfMon=this._options?._profName?new h(this._options._profName):void 0,this._deliveryQueue=this._options?.deliveryQueue}dispose(){this._disposed||(this._disposed=!0,this._deliveryQueue?.current===this&&this._deliveryQueue.reset(),this._listeners&&(this._listeners=void 0,this._size=0),this._options?.onDidRemoveLastListener?.(),this._leakageMon?.dispose())}get event(){return this._event??=(e,t,i)=>{if(this._leakageMon&&this._size>this._leakageMon.threshold**2){const e=`[${this._leakageMon.name}] REFUSES to accept new listeners because it exceeded its threshold by far (${this._size} vs ${this._leakageMon.threshold})`;console.warn(e);const t=this._leakageMon.getMostFrequentStack()??["UNKNOWN stack",-1],i=new f(`${e}. HINT: Stack shows most frequent listener (${t[1]}-times)`,t[0]);return(this._options?.onListenerError||s.onUnexpectedError)(i),n.Disposable.None}if(this._disposed)return n.Disposable.None;t&&(e=e.bind(t));const r=new g(e);let o;this._leakageMon&&this._size>=Math.ceil(.2*this._leakageMon.threshold)&&(r.stack=u.create(),o=this._leakageMon.check(r.stack,this._size+1)),this._listeners?this._listeners instanceof g?(this._deliveryQueue??=new v,this._listeners=[this._listeners,r]):this._listeners.push(r):(this._options?.onWillAddFirstListener?.(this),this._listeners=r,this._options?.onDidAddFirstListener?.(this)),this._size++;const a=(0,n.toDisposable)((()=>{o?.(),this._removeListener(r)}));return i instanceof n.DisposableStore?i.add(a):Array.isArray(i)&&i.push(a),a},this._event}_removeListener(e){if(this._options?.onWillRemoveListener?.(this),!this._listeners)return;if(1===this._size)return this._listeners=void 0,this._options?.onDidRemoveLastListener?.(this),void(this._size=0);const t=this._listeners,i=t.indexOf(e);if(-1===i)throw console.log("disposed?",this._disposed),console.log("size?",this._size),console.log("arr?",JSON.stringify(this._listeners)),new Error("Attempted to dispose unknown listener");this._size--,t[i]=void 0;const s=this._deliveryQueue.current===this;if(2*this._size<=t.length){let e=0;for(let i=0;i0}}t.Emitter=m,t.createEventDeliveryQueue=()=>new v;class v{constructor(){this.i=-1,this.end=0}enqueue(e,t,i){this.i=0,this.end=i,this.current=e,this.value=t}reset(){this.i=this.end,this.current=void 0,this.value=void 0}}t.AsyncEmitter=class extends m{async fireAsync(e,t,i){if(this._listeners)for(this._asyncDeliveryQueue||(this._asyncDeliveryQueue=new o.LinkedList),((e,t)=>{if(e instanceof g)t(e);else for(let i=0;ithis._asyncDeliveryQueue.push([t.value,e])));this._asyncDeliveryQueue.size>0&&!t.isCancellationRequested;){const[e,r]=this._asyncDeliveryQueue.shift(),n=[],o={...r,token:t,waitUntil:t=>{if(Object.isFrozen(n))throw new Error("waitUntil can NOT be called asynchronous");i&&(t=i(t,e)),n.push(t)}};try{e(o)}catch(e){(0,s.onUnexpectedError)(e);continue}Object.freeze(n),await Promise.allSettled(n).then((e=>{for(const t of e)"rejected"===t.status&&(0,s.onUnexpectedError)(t.reason)}))}}};class S extends m{get isPaused(){return 0!==this._isPaused}constructor(e){super(e),this._isPaused=0,this._eventQueue=new o.LinkedList,this._mergeFn=e?.merge}pause(){this._isPaused++}resume(){if(0!==this._isPaused&&0==--this._isPaused)if(this._mergeFn){if(this._eventQueue.size>0){const e=Array.from(this._eventQueue);this._eventQueue.clear(),super.fire(this._mergeFn(e))}}else for(;!this._isPaused&&0!==this._eventQueue.size;)super.fire(this._eventQueue.shift())}fire(e){this._size&&(0!==this._isPaused?this._eventQueue.push(e):super.fire(e))}}t.PauseableEmitter=S,t.DebounceEmitter=class extends S{constructor(e){super(e),this._delay=e.delay??100}fire(e){this._handle||(this.pause(),this._handle=setTimeout((()=>{this._handle=void 0,this.resume()}),this._delay)),super.fire(e)}},t.MicrotaskEmitter=class extends m{constructor(e){super(e),this._queuedEvents=[],this._mergeFn=e?.merge}fire(e){this.hasListeners()&&(this._queuedEvents.push(e),1===this._queuedEvents.length&&queueMicrotask((()=>{this._mergeFn?super.fire(this._mergeFn(this._queuedEvents)):this._queuedEvents.forEach((e=>super.fire(e))),this._queuedEvents=[]})))}};class b{constructor(){this.hasListeners=!1,this.events=[],this.emitter=new m({onWillAddFirstListener:()=>this.onFirstListenerAdd(),onDidRemoveLastListener:()=>this.onLastListenerRemove()})}get event(){return this.emitter.event}add(e){const t={event:e,listener:null};return this.events.push(t),this.hasListeners&&this.hook(t),(0,n.toDisposable)((0,r.createSingleCallFunction)((()=>{this.hasListeners&&this.unhook(t);const e=this.events.indexOf(t);this.events.splice(e,1)})))}onFirstListenerAdd(){this.hasListeners=!0,this.events.forEach((e=>this.hook(e)))}onLastListenerRemove(){this.hasListeners=!1,this.events.forEach((e=>this.unhook(e)))}hook(e){e.listener=e.event((e=>this.emitter.fire(e)))}unhook(e){e.listener?.dispose(),e.listener=null}dispose(){this.emitter.dispose();for(const e of this.events)e.listener?.dispose();this.events=[]}}t.EventMultiplexer=b,t.DynamicListEventMultiplexer=class{constructor(e,t,i,s){this._store=new n.DisposableStore;const r=this._store.add(new b),o=this._store.add(new n.DisposableMap);function a(e){o.set(e,r.add(s(e)))}for(const t of e)a(t);this._store.add(t((e=>{a(e)}))),this._store.add(i((e=>{o.deleteAndDispose(e)}))),this.event=r.event}dispose(){this._store.dispose()}},t.EventBufferer=class{constructor(){this.data=[]}wrapEvent(e,t,i){return(s,r,n)=>e((e=>{const n=this.data[this.data.length-1];if(!t)return void(n?n.buffers.push((()=>s.call(r,e))):s.call(r,e));const o=n;o?(o.items??=[],o.items.push(e),0===o.buffers.length&&n.buffers.push((()=>{o.reducedResult??=i?o.items.reduce(t,i):o.items.reduce(t),s.call(r,o.reducedResult)}))):s.call(r,t(i,e))}),void 0,n)}bufferEvents(e){const t={buffers:new Array};this.data.push(t);const i=e();return this.data.pop(),t.buffers.forEach((e=>e())),i}},t.Relay=class{constructor(){this.listening=!1,this.inputEvent=l.None,this.inputEventListener=n.Disposable.None,this.emitter=new m({onDidAddFirstListener:()=>{this.listening=!0,this.inputEventListener=this.inputEvent(this.emitter.fire,this.emitter)},onDidRemoveLastListener:()=>{this.listening=!1,this.inputEventListener.dispose()}}),this.event=this.emitter.event}set input(e){this.inputEvent=e,this.listening&&(this.inputEventListener.dispose(),this.inputEventListener=e(this.emitter.fire,this.emitter))}dispose(){this.inputEventListener.dispose(),this.emitter.dispose()}},t.ValueWithChangeEvent=class{static const(e){return new C(e)}constructor(e){this._value=e,this._onDidChange=new m,this.onDidChange=this._onDidChange.event}get value(){return this._value}set value(e){e!==this._value&&(this._value=e,this._onDidChange.fire(void 0))}};class C{constructor(e){this.value=e,this.onDidChange=l.None}}},8841:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.createSingleCallFunction=function(e,t){const i=this;let s,r=!1;return function(){if(r)return s;if(r=!0,t)try{s=e.apply(i,arguments)}finally{t()}else s=e.apply(i,arguments);return s}}},6304:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.StringSHA1=t.Hasher=void 0,t.hash=function(e){return a(e,0)},t.doHash=a,t.numberHash=l,t.stringHash=h,t.toHexString=_;const o=n(i(1316));function a(e,t){switch(typeof e){case"object":return null===e?l(349,t):Array.isArray(e)?(i=e,s=l(104579,s=t),i.reduce(((e,t)=>a(t,e)),s)):function(e,t){return t=l(181387,t),Object.keys(e).sort().reduce(((t,i)=>(t=h(i,t),a(e[i],t))),t)}(e,t);case"string":return h(e,t);case"boolean":return function(e,t){return l(e?433:863,t)}(e,t);case"number":return l(e,t);case"undefined":return l(937,t);default:return l(617,t)}var i,s}function l(e,t){return(t<<5)-t+e|0}function h(e,t){t=l(149417,t);for(let i=0,s=e.length;i>>s)>>>0}function u(e,t=0,i=e.byteLength,s=0){for(let r=0;re.toString(16).padStart(2,"0"))).join(""):function(e,t,i="0"){for(;e.length>>0).toString(16),t/4)}t.Hasher=class{constructor(){this._value=0}get value(){return this._value}hash(e){return this._value=a(e,this._value),this._value}},function(e){e[e.BLOCK_SIZE=64]="BLOCK_SIZE",e[e.UNICODE_REPLACEMENT=65533]="UNICODE_REPLACEMENT"}(c||(c={}));class f{static{this._bigBlock32=new DataView(new ArrayBuffer(320))}constructor(){this._h0=1732584193,this._h1=4023233417,this._h2=2562383102,this._h3=271733878,this._h4=3285377520,this._buff=new Uint8Array(c.BLOCK_SIZE+3),this._buffDV=new DataView(this._buff.buffer),this._buffLen=0,this._totalLen=0,this._leftoverHighSurrogate=0,this._finished=!1}update(e){const t=e.length;if(0===t)return;const i=this._buff;let s,r,n=this._buffLen,a=this._leftoverHighSurrogate;for(0!==a?(s=a,r=-1,a=0):(s=e.charCodeAt(0),r=0);;){let l=s;if(o.isHighSurrogate(s)){if(!(r+1>>6,e[t++]=128|(63&i)>>>0):i<65536?(e[t++]=224|(61440&i)>>>12,e[t++]=128|(4032&i)>>>6,e[t++]=128|(63&i)>>>0):(e[t++]=240|(1835008&i)>>>18,e[t++]=128|(258048&i)>>>12,e[t++]=128|(4032&i)>>>6,e[t++]=128|(63&i)>>>0),t>=c.BLOCK_SIZE&&(this._step(),t-=c.BLOCK_SIZE,this._totalLen+=c.BLOCK_SIZE,e[0]=e[c.BLOCK_SIZE+0],e[1]=e[c.BLOCK_SIZE+1],e[2]=e[c.BLOCK_SIZE+2]),t}digest(){return this._finished||(this._finished=!0,this._leftoverHighSurrogate&&(this._leftoverHighSurrogate=0,this._buffLen=this._push(this._buff,this._buffLen,c.UNICODE_REPLACEMENT)),this._totalLen+=this._buffLen,this._wrapUp()),_(this._h0)+_(this._h1)+_(this._h2)+_(this._h3)+_(this._h4)}_wrapUp(){this._buff[this._buffLen++]=128,u(this._buff,this._buffLen),this._buffLen>56&&(this._step(),u(this._buff));const e=8*this._totalLen;this._buffDV.setUint32(56,Math.floor(e/4294967296),!1),this._buffDV.setUint32(60,e%4294967296,!1),this._step()}_step(){const e=f._bigBlock32,t=this._buffDV;for(let i=0;i<64;i+=4)e.setUint32(i,t.getUint32(i,!1),!1);for(let t=64;t<320;t+=4)e.setUint32(t,d(e.getUint32(t-12,!1)^e.getUint32(t-32,!1)^e.getUint32(t-56,!1)^e.getUint32(t-64,!1),1),!1);let i,s,r,n=this._h0,o=this._h1,a=this._h2,l=this._h3,h=this._h4;for(let t=0;t<80;t++)t<20?(i=o&a|~o&l,s=1518500249):t<40?(i=o^a^l,s=1859775393):t<60?(i=o&a|o&l|a&l,s=2400959708):(i=o^a^l,s=3395469782),r=d(n,5)+i+h+s+e.getUint32(4*t,!1)&4294967295,h=l,l=a,a=d(o,30),o=n,n=r;this._h0=this._h0+n&4294967295,this._h1=this._h1+o&4294967295,this._h2=this._h2+a&4294967295,this._h3=this._h3+l&4294967295,this._h4=this._h4+h&4294967295}}t.StringSHA1=f},4218:(e,t)=>{var i;Object.defineProperty(t,"__esModule",{value:!0}),t.Iterable=void 0,function(e){function t(e){return e&&"object"==typeof e&&"function"==typeof e[Symbol.iterator]}e.is=t;const i=Object.freeze([]);function*s(e){yield e}e.empty=function(){return i},e.single=s,e.wrap=function(e){return t(e)?e:s(e)},e.from=function(e){return e||i},e.reverse=function*(e){for(let t=e.length-1;t>=0;t--)yield e[t]},e.isEmpty=function(e){return!e||!0===e[Symbol.iterator]().next().done},e.first=function(e){return e[Symbol.iterator]().next().value},e.some=function(e,t){let i=0;for(const s of e)if(t(s,i++))return!0;return!1},e.find=function(e,t){for(const i of e)if(t(i))return i},e.filter=function*(e,t){for(const i of e)t(i)&&(yield i)},e.map=function*(e,t){let i=0;for(const s of e)yield t(s,i++)},e.flatMap=function*(e,t){let i=0;for(const s of e)yield*t(s,i++)},e.concat=function*(...e){for(const t of e)yield*t},e.reduce=function(e,t,i){let s=i;for(const i of e)s=t(s,i);return s},e.slice=function*(e,t,i=e.length){for(t<0&&(t+=e.length),i<0?i+=e.length:i>e.length&&(i=e.length);tr}]},e.asyncToArray=async function(e){const t=[];for await(const i of e)t.push(i);return Promise.resolve(t)}}(i||(t.Iterable=i={}))},7883:(e,t)=>{var i,s;Object.defineProperty(t,"__esModule",{value:!0}),t.KeyMod=t.KeyCodeUtils=t.ScanCodeUtils=t.NATIVE_WINDOWS_KEY_CODE_TO_KEY_CODE=t.EVENT_KEY_CODE_MAP=t.ScanCode=t.KeyCode=void 0,t.KeyChord=function(e,t){return(e|(65535&t)<<16>>>0)>>>0},function(e){e[e.DependsOnKbLayout=-1]="DependsOnKbLayout",e[e.Unknown=0]="Unknown",e[e.Backspace=1]="Backspace",e[e.Tab=2]="Tab",e[e.Enter=3]="Enter",e[e.Shift=4]="Shift",e[e.Ctrl=5]="Ctrl",e[e.Alt=6]="Alt",e[e.PauseBreak=7]="PauseBreak",e[e.CapsLock=8]="CapsLock",e[e.Escape=9]="Escape",e[e.Space=10]="Space",e[e.PageUp=11]="PageUp",e[e.PageDown=12]="PageDown",e[e.End=13]="End",e[e.Home=14]="Home",e[e.LeftArrow=15]="LeftArrow",e[e.UpArrow=16]="UpArrow",e[e.RightArrow=17]="RightArrow",e[e.DownArrow=18]="DownArrow",e[e.Insert=19]="Insert",e[e.Delete=20]="Delete",e[e.Digit0=21]="Digit0",e[e.Digit1=22]="Digit1",e[e.Digit2=23]="Digit2",e[e.Digit3=24]="Digit3",e[e.Digit4=25]="Digit4",e[e.Digit5=26]="Digit5",e[e.Digit6=27]="Digit6",e[e.Digit7=28]="Digit7",e[e.Digit8=29]="Digit8",e[e.Digit9=30]="Digit9",e[e.KeyA=31]="KeyA",e[e.KeyB=32]="KeyB",e[e.KeyC=33]="KeyC",e[e.KeyD=34]="KeyD",e[e.KeyE=35]="KeyE",e[e.KeyF=36]="KeyF",e[e.KeyG=37]="KeyG",e[e.KeyH=38]="KeyH",e[e.KeyI=39]="KeyI",e[e.KeyJ=40]="KeyJ",e[e.KeyK=41]="KeyK",e[e.KeyL=42]="KeyL",e[e.KeyM=43]="KeyM",e[e.KeyN=44]="KeyN",e[e.KeyO=45]="KeyO",e[e.KeyP=46]="KeyP",e[e.KeyQ=47]="KeyQ",e[e.KeyR=48]="KeyR",e[e.KeyS=49]="KeyS",e[e.KeyT=50]="KeyT",e[e.KeyU=51]="KeyU",e[e.KeyV=52]="KeyV",e[e.KeyW=53]="KeyW",e[e.KeyX=54]="KeyX",e[e.KeyY=55]="KeyY",e[e.KeyZ=56]="KeyZ",e[e.Meta=57]="Meta",e[e.ContextMenu=58]="ContextMenu",e[e.F1=59]="F1",e[e.F2=60]="F2",e[e.F3=61]="F3",e[e.F4=62]="F4",e[e.F5=63]="F5",e[e.F6=64]="F6",e[e.F7=65]="F7",e[e.F8=66]="F8",e[e.F9=67]="F9",e[e.F10=68]="F10",e[e.F11=69]="F11",e[e.F12=70]="F12",e[e.F13=71]="F13",e[e.F14=72]="F14",e[e.F15=73]="F15",e[e.F16=74]="F16",e[e.F17=75]="F17",e[e.F18=76]="F18",e[e.F19=77]="F19",e[e.F20=78]="F20",e[e.F21=79]="F21",e[e.F22=80]="F22",e[e.F23=81]="F23",e[e.F24=82]="F24",e[e.NumLock=83]="NumLock",e[e.ScrollLock=84]="ScrollLock",e[e.Semicolon=85]="Semicolon",e[e.Equal=86]="Equal",e[e.Comma=87]="Comma",e[e.Minus=88]="Minus",e[e.Period=89]="Period",e[e.Slash=90]="Slash",e[e.Backquote=91]="Backquote",e[e.BracketLeft=92]="BracketLeft",e[e.Backslash=93]="Backslash",e[e.BracketRight=94]="BracketRight",e[e.Quote=95]="Quote",e[e.OEM_8=96]="OEM_8",e[e.IntlBackslash=97]="IntlBackslash",e[e.Numpad0=98]="Numpad0",e[e.Numpad1=99]="Numpad1",e[e.Numpad2=100]="Numpad2",e[e.Numpad3=101]="Numpad3",e[e.Numpad4=102]="Numpad4",e[e.Numpad5=103]="Numpad5",e[e.Numpad6=104]="Numpad6",e[e.Numpad7=105]="Numpad7",e[e.Numpad8=106]="Numpad8",e[e.Numpad9=107]="Numpad9",e[e.NumpadMultiply=108]="NumpadMultiply",e[e.NumpadAdd=109]="NumpadAdd",e[e.NUMPAD_SEPARATOR=110]="NUMPAD_SEPARATOR",e[e.NumpadSubtract=111]="NumpadSubtract",e[e.NumpadDecimal=112]="NumpadDecimal",e[e.NumpadDivide=113]="NumpadDivide",e[e.KEY_IN_COMPOSITION=114]="KEY_IN_COMPOSITION",e[e.ABNT_C1=115]="ABNT_C1",e[e.ABNT_C2=116]="ABNT_C2",e[e.AudioVolumeMute=117]="AudioVolumeMute",e[e.AudioVolumeUp=118]="AudioVolumeUp",e[e.AudioVolumeDown=119]="AudioVolumeDown",e[e.BrowserSearch=120]="BrowserSearch",e[e.BrowserHome=121]="BrowserHome",e[e.BrowserBack=122]="BrowserBack",e[e.BrowserForward=123]="BrowserForward",e[e.MediaTrackNext=124]="MediaTrackNext",e[e.MediaTrackPrevious=125]="MediaTrackPrevious",e[e.MediaStop=126]="MediaStop",e[e.MediaPlayPause=127]="MediaPlayPause",e[e.LaunchMediaPlayer=128]="LaunchMediaPlayer",e[e.LaunchMail=129]="LaunchMail",e[e.LaunchApp2=130]="LaunchApp2",e[e.Clear=131]="Clear",e[e.MAX_VALUE=132]="MAX_VALUE"}(i||(t.KeyCode=i={})),function(e){e[e.DependsOnKbLayout=-1]="DependsOnKbLayout",e[e.None=0]="None",e[e.Hyper=1]="Hyper",e[e.Super=2]="Super",e[e.Fn=3]="Fn",e[e.FnLock=4]="FnLock",e[e.Suspend=5]="Suspend",e[e.Resume=6]="Resume",e[e.Turbo=7]="Turbo",e[e.Sleep=8]="Sleep",e[e.WakeUp=9]="WakeUp",e[e.KeyA=10]="KeyA",e[e.KeyB=11]="KeyB",e[e.KeyC=12]="KeyC",e[e.KeyD=13]="KeyD",e[e.KeyE=14]="KeyE",e[e.KeyF=15]="KeyF",e[e.KeyG=16]="KeyG",e[e.KeyH=17]="KeyH",e[e.KeyI=18]="KeyI",e[e.KeyJ=19]="KeyJ",e[e.KeyK=20]="KeyK",e[e.KeyL=21]="KeyL",e[e.KeyM=22]="KeyM",e[e.KeyN=23]="KeyN",e[e.KeyO=24]="KeyO",e[e.KeyP=25]="KeyP",e[e.KeyQ=26]="KeyQ",e[e.KeyR=27]="KeyR",e[e.KeyS=28]="KeyS",e[e.KeyT=29]="KeyT",e[e.KeyU=30]="KeyU",e[e.KeyV=31]="KeyV",e[e.KeyW=32]="KeyW",e[e.KeyX=33]="KeyX",e[e.KeyY=34]="KeyY",e[e.KeyZ=35]="KeyZ",e[e.Digit1=36]="Digit1",e[e.Digit2=37]="Digit2",e[e.Digit3=38]="Digit3",e[e.Digit4=39]="Digit4",e[e.Digit5=40]="Digit5",e[e.Digit6=41]="Digit6",e[e.Digit7=42]="Digit7",e[e.Digit8=43]="Digit8",e[e.Digit9=44]="Digit9",e[e.Digit0=45]="Digit0",e[e.Enter=46]="Enter",e[e.Escape=47]="Escape",e[e.Backspace=48]="Backspace",e[e.Tab=49]="Tab",e[e.Space=50]="Space",e[e.Minus=51]="Minus",e[e.Equal=52]="Equal",e[e.BracketLeft=53]="BracketLeft",e[e.BracketRight=54]="BracketRight",e[e.Backslash=55]="Backslash",e[e.IntlHash=56]="IntlHash",e[e.Semicolon=57]="Semicolon",e[e.Quote=58]="Quote",e[e.Backquote=59]="Backquote",e[e.Comma=60]="Comma",e[e.Period=61]="Period",e[e.Slash=62]="Slash",e[e.CapsLock=63]="CapsLock",e[e.F1=64]="F1",e[e.F2=65]="F2",e[e.F3=66]="F3",e[e.F4=67]="F4",e[e.F5=68]="F5",e[e.F6=69]="F6",e[e.F7=70]="F7",e[e.F8=71]="F8",e[e.F9=72]="F9",e[e.F10=73]="F10",e[e.F11=74]="F11",e[e.F12=75]="F12",e[e.PrintScreen=76]="PrintScreen",e[e.ScrollLock=77]="ScrollLock",e[e.Pause=78]="Pause",e[e.Insert=79]="Insert",e[e.Home=80]="Home",e[e.PageUp=81]="PageUp",e[e.Delete=82]="Delete",e[e.End=83]="End",e[e.PageDown=84]="PageDown",e[e.ArrowRight=85]="ArrowRight",e[e.ArrowLeft=86]="ArrowLeft",e[e.ArrowDown=87]="ArrowDown",e[e.ArrowUp=88]="ArrowUp",e[e.NumLock=89]="NumLock",e[e.NumpadDivide=90]="NumpadDivide",e[e.NumpadMultiply=91]="NumpadMultiply",e[e.NumpadSubtract=92]="NumpadSubtract",e[e.NumpadAdd=93]="NumpadAdd",e[e.NumpadEnter=94]="NumpadEnter",e[e.Numpad1=95]="Numpad1",e[e.Numpad2=96]="Numpad2",e[e.Numpad3=97]="Numpad3",e[e.Numpad4=98]="Numpad4",e[e.Numpad5=99]="Numpad5",e[e.Numpad6=100]="Numpad6",e[e.Numpad7=101]="Numpad7",e[e.Numpad8=102]="Numpad8",e[e.Numpad9=103]="Numpad9",e[e.Numpad0=104]="Numpad0",e[e.NumpadDecimal=105]="NumpadDecimal",e[e.IntlBackslash=106]="IntlBackslash",e[e.ContextMenu=107]="ContextMenu",e[e.Power=108]="Power",e[e.NumpadEqual=109]="NumpadEqual",e[e.F13=110]="F13",e[e.F14=111]="F14",e[e.F15=112]="F15",e[e.F16=113]="F16",e[e.F17=114]="F17",e[e.F18=115]="F18",e[e.F19=116]="F19",e[e.F20=117]="F20",e[e.F21=118]="F21",e[e.F22=119]="F22",e[e.F23=120]="F23",e[e.F24=121]="F24",e[e.Open=122]="Open",e[e.Help=123]="Help",e[e.Select=124]="Select",e[e.Again=125]="Again",e[e.Undo=126]="Undo",e[e.Cut=127]="Cut",e[e.Copy=128]="Copy",e[e.Paste=129]="Paste",e[e.Find=130]="Find",e[e.AudioVolumeMute=131]="AudioVolumeMute",e[e.AudioVolumeUp=132]="AudioVolumeUp",e[e.AudioVolumeDown=133]="AudioVolumeDown",e[e.NumpadComma=134]="NumpadComma",e[e.IntlRo=135]="IntlRo",e[e.KanaMode=136]="KanaMode",e[e.IntlYen=137]="IntlYen",e[e.Convert=138]="Convert",e[e.NonConvert=139]="NonConvert",e[e.Lang1=140]="Lang1",e[e.Lang2=141]="Lang2",e[e.Lang3=142]="Lang3",e[e.Lang4=143]="Lang4",e[e.Lang5=144]="Lang5",e[e.Abort=145]="Abort",e[e.Props=146]="Props",e[e.NumpadParenLeft=147]="NumpadParenLeft",e[e.NumpadParenRight=148]="NumpadParenRight",e[e.NumpadBackspace=149]="NumpadBackspace",e[e.NumpadMemoryStore=150]="NumpadMemoryStore",e[e.NumpadMemoryRecall=151]="NumpadMemoryRecall",e[e.NumpadMemoryClear=152]="NumpadMemoryClear",e[e.NumpadMemoryAdd=153]="NumpadMemoryAdd",e[e.NumpadMemorySubtract=154]="NumpadMemorySubtract",e[e.NumpadClear=155]="NumpadClear",e[e.NumpadClearEntry=156]="NumpadClearEntry",e[e.ControlLeft=157]="ControlLeft",e[e.ShiftLeft=158]="ShiftLeft",e[e.AltLeft=159]="AltLeft",e[e.MetaLeft=160]="MetaLeft",e[e.ControlRight=161]="ControlRight",e[e.ShiftRight=162]="ShiftRight",e[e.AltRight=163]="AltRight",e[e.MetaRight=164]="MetaRight",e[e.BrightnessUp=165]="BrightnessUp",e[e.BrightnessDown=166]="BrightnessDown",e[e.MediaPlay=167]="MediaPlay",e[e.MediaRecord=168]="MediaRecord",e[e.MediaFastForward=169]="MediaFastForward",e[e.MediaRewind=170]="MediaRewind",e[e.MediaTrackNext=171]="MediaTrackNext",e[e.MediaTrackPrevious=172]="MediaTrackPrevious",e[e.MediaStop=173]="MediaStop",e[e.Eject=174]="Eject",e[e.MediaPlayPause=175]="MediaPlayPause",e[e.MediaSelect=176]="MediaSelect",e[e.LaunchMail=177]="LaunchMail",e[e.LaunchApp2=178]="LaunchApp2",e[e.LaunchApp1=179]="LaunchApp1",e[e.SelectTask=180]="SelectTask",e[e.LaunchScreenSaver=181]="LaunchScreenSaver",e[e.BrowserSearch=182]="BrowserSearch",e[e.BrowserHome=183]="BrowserHome",e[e.BrowserBack=184]="BrowserBack",e[e.BrowserForward=185]="BrowserForward",e[e.BrowserStop=186]="BrowserStop",e[e.BrowserRefresh=187]="BrowserRefresh",e[e.BrowserFavorites=188]="BrowserFavorites",e[e.ZoomToggle=189]="ZoomToggle",e[e.MailReply=190]="MailReply",e[e.MailForward=191]="MailForward",e[e.MailSend=192]="MailSend",e[e.MAX_VALUE=193]="MAX_VALUE"}(s||(t.ScanCode=s={}));class r{constructor(){this._keyCodeToStr=[],this._strToKeyCode=Object.create(null)}define(e,t){this._keyCodeToStr[e]=t,this._strToKeyCode[t.toLowerCase()]=e}keyCodeToStr(e){return this._keyCodeToStr[e]}strToKeyCode(e){return this._strToKeyCode[e.toLowerCase()]||i.Unknown}}const n=new r,o=new r,a=new r;t.EVENT_KEY_CODE_MAP=new Array(230),t.NATIVE_WINDOWS_KEY_CODE_TO_KEY_CODE={};const l=[],h=Object.create(null),c=Object.create(null);var d,u;t.ScanCodeUtils={lowerCaseToEnum:e=>c[e]||s.None,toEnum:e=>h[e]||s.None,toString:e=>l[e]||"None"},function(e){e.toString=function(e){return n.keyCodeToStr(e)},e.fromString=function(e){return n.strToKeyCode(e)},e.toUserSettingsUS=function(e){return o.keyCodeToStr(e)},e.toUserSettingsGeneral=function(e){return a.keyCodeToStr(e)},e.fromUserSettings=function(e){return o.strToKeyCode(e)||a.strToKeyCode(e)},e.toElectronAccelerator=function(e){if(e>=i.Numpad0&&e<=i.NumpadDivide)return null;switch(e){case i.UpArrow:return"Up";case i.DownArrow:return"Down";case i.LeftArrow:return"Left";case i.RightArrow:return"Right"}return n.keyCodeToStr(e)}}(d||(t.KeyCodeUtils=d={})),function(e){e[e.CtrlCmd=2048]="CtrlCmd",e[e.Shift=1024]="Shift",e[e.Alt=512]="Alt",e[e.WinCtrl=256]="WinCtrl"}(u||(t.KeyMod=u={}))},2811:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ResolvedKeybinding=t.ResolvedChord=t.Keybinding=t.ScanCodeChord=t.KeyCodeChord=void 0,t.decodeKeybinding=function(e,t){if("number"==typeof e){if(0===e)return null;const i=(65535&e)>>>0,s=(4294901760&e)>>>16;return new c(0!==s?[a(i,t),a(s,t)]:[a(i,t)])}{const i=[];for(let s=0;s{Object.defineProperty(t,"__esModule",{value:!0}),t.Lazy=void 0,t.Lazy=class{constructor(e){this.executor=e,this._didRun=!1}get hasValue(){return this._didRun}get value(){if(!this._didRun)try{this._value=this.executor()}catch(e){this._error=e}finally{this._didRun=!0}if(this._error)throw this._error;return this._value}get rawValue(){return this._value}}},7150:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DisposableMap=t.ImmortalReference=t.AsyncReferenceCollection=t.ReferenceCollection=t.SafeDisposable=t.RefCountedDisposable=t.MandatoryMutableDisposable=t.MutableDisposable=t.Disposable=t.DisposableStore=t.DisposableTracker=void 0,t.setDisposableTracker=function(e){l=e},t.trackDisposable=c,t.markAsDisposed=d,t.markAsSingleton=function(e){return l?.markAsSingleton(e),e},t.isDisposable=_,t.dispose=f,t.disposeIfDisposable=function(e){for(const t of e)_(t)&&t.dispose();return[]},t.combinedDisposable=function(...e){const t=p((()=>f(e)));return function(e,t){if(l)for(const i of e)l.setParent(i,t)}(e,t),t},t.toDisposable=p,t.disposeOnReturn=function(e){const t=new g;try{e(t)}finally{t.dispose()}};const s=i(3058),r=i(9087),n=i(2608),o=i(8841),a=i(4218);let l=null;class h{constructor(){this.livingDisposables=new Map}static{this.idx=0}getDisposableData(e){let t=this.livingDisposables.get(e);return t||(t={parent:null,source:null,isSingleton:!1,value:e,idx:h.idx++},this.livingDisposables.set(e,t)),t}trackDisposable(e){const t=this.getDisposableData(e);t.source||(t.source=(new Error).stack)}setParent(e,t){this.getDisposableData(e).parent=t}markAsDisposed(e){this.livingDisposables.delete(e)}markAsSingleton(e){this.getDisposableData(e).isSingleton=!0}getRootParent(e,t){const i=t.get(e);if(i)return i;const s=e.parent?this.getRootParent(this.getDisposableData(e.parent),t):e;return t.set(e,s),s}getTrackedDisposables(){const e=new Map;return[...this.livingDisposables.entries()].filter((([,t])=>null!==t.source&&!this.getRootParent(t,e).isSingleton)).flatMap((([e])=>e))}computeLeakingDisposables(e=10,t){let i;if(t)i=t;else{const e=new Map,t=[...this.livingDisposables.values()].filter((t=>null!==t.source&&!this.getRootParent(t,e).isSingleton));if(0===t.length)return;const s=new Set(t.map((e=>e.value)));if(i=t.filter((e=>!(e.parent&&s.has(e.parent)))),0===i.length)throw new Error("There are cyclic diposable chains!")}if(!i)return;function o(e){const t=e.source.split("\n").map((e=>e.trim().replace("at ",""))).filter((e=>""!==e));return function(e,t){for(;e.length>0&&t.some((t=>"string"==typeof t?t===e[0]:e[0].match(t)));)e.shift()}(t,["Error",/^trackDisposable \(.*\)$/,/^DisposableTracker.trackDisposable \(.*\)$/]),t.reverse()}const a=new n.SetMap;for(const e of i){const t=o(e);for(let i=0;i<=t.length;i++)a.add(t.slice(0,i).join("\n"),e)}i.sort((0,s.compareBy)((e=>e.idx),s.numberComparator));let l="",h=0;for(const t of i.slice(0,e)){h++;const e=o(t),s=[];for(let t=0;to(e)[t])),(e=>e));delete h[e[t]];for(const[e,t]of Object.entries(h))s.unshift(` - stacktraces of ${t.length} other leaks continue with ${e}`);s.unshift(n)}l+=`\n\n\n==================== Leaking disposable ${h}/${i.length}: ${t.value.constructor.name} ====================\n${s.join("\n")}\n============================================================\n\n`}return i.length>e&&(l+=`\n\n\n... and ${i.length-e} more leaking disposables\n\n`),{leaks:i,details:l}}}function c(e){return l?.trackDisposable(e),e}function d(e){l?.markAsDisposed(e)}function u(e,t){l?.setParent(e,t)}function _(e){return"object"==typeof e&&null!==e&&"function"==typeof e.dispose&&0===e.dispose.length}function f(e){if(a.Iterable.is(e)){const t=[];for(const i of e)if(i)try{i.dispose()}catch(e){t.push(e)}if(1===t.length)throw t[0];if(t.length>1)throw new AggregateError(t,"Encountered errors while disposing of store");return Array.isArray(e)?[]:e}if(e)return e.dispose(),e}function p(e){const t=c({dispose:(0,o.createSingleCallFunction)((()=>{d(t),e()}))});return t}t.DisposableTracker=h;class g{static{this.DISABLE_DISPOSED_WARNING=!1}constructor(){this._toDispose=new Set,this._isDisposed=!1,c(this)}dispose(){this._isDisposed||(d(this),this._isDisposed=!0,this.clear())}get isDisposed(){return this._isDisposed}clear(){if(0!==this._toDispose.size)try{f(this._toDispose)}finally{this._toDispose.clear()}}add(e){if(!e)return e;if(e===this)throw new Error("Cannot register a disposable on itself!");return u(e,this),this._isDisposed?g.DISABLE_DISPOSED_WARNING||console.warn(new Error("Trying to add a disposable to a DisposableStore that has already been disposed of. The added object will be leaked!").stack):this._toDispose.add(e),e}delete(e){if(e){if(e===this)throw new Error("Cannot dispose a disposable on itself!");this._toDispose.delete(e),e.dispose()}}deleteAndLeak(e){e&&this._toDispose.has(e)&&(this._toDispose.delete(e),u(e,null))}}t.DisposableStore=g;class m{static{this.None=Object.freeze({dispose(){}})}constructor(){this._store=new g,c(this),u(this._store,this)}dispose(){d(this),this._store.dispose()}_register(e){if(e===this)throw new Error("Cannot register a disposable on itself!");return this._store.add(e)}}t.Disposable=m;class v{constructor(){this._isDisposed=!1,c(this)}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),e&&u(e,this),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,d(this),this._value?.dispose(),this._value=void 0}clearAndLeak(){const e=this._value;return this._value=void 0,e&&u(e,null),e}}t.MutableDisposable=v,t.MandatoryMutableDisposable=class{constructor(e){this._disposable=new v,this._isDisposed=!1,this._disposable.value=e}get value(){return this._disposable.value}set value(e){this._isDisposed||e===this._disposable.value||(this._disposable.value=e)}dispose(){this._isDisposed=!0,this._disposable.dispose()}},t.RefCountedDisposable=class{constructor(e){this._disposable=e,this._counter=1}acquire(){return this._counter++,this}release(){return 0==--this._counter&&this._disposable.dispose(),this}},t.SafeDisposable=class{constructor(){this.dispose=()=>{},this.unset=()=>{},this.isset=()=>!1,c(this)}set(e){let t=e;return this.unset=()=>t=void 0,this.isset=()=>void 0!==t,this.dispose=()=>{t&&(t(),t=void 0,d(this))},this}},t.ReferenceCollection=class{constructor(){this.references=new Map}acquire(e,...t){let i=this.references.get(e);i||(i={counter:0,object:this.createReferencedObject(e,...t)},this.references.set(e,i));const{object:s}=i,r=(0,o.createSingleCallFunction)((()=>{0==--i.counter&&(this.destroyReferencedObject(e,i.object),this.references.delete(e))}));return i.counter++,{object:s,dispose:r}}},t.AsyncReferenceCollection=class{constructor(e){this.referenceCollection=e}async acquire(e,...t){const i=this.referenceCollection.acquire(e,...t);try{return{object:await i.object,dispose:()=>i.dispose()}}catch(e){throw i.dispose(),e}}},t.ImmortalReference=class{constructor(e){this.object=e}dispose(){}};class S{constructor(){this._store=new Map,this._isDisposed=!1,c(this)}dispose(){d(this),this._isDisposed=!0,this.clearAndDisposeAll()}clearAndDisposeAll(){if(this._store.size)try{f(this._store.values())}finally{this._store.clear()}}has(e){return this._store.has(e)}get size(){return this._store.size}get(e){return this._store.get(e)}set(e,t,i=!1){this._isDisposed&&console.warn(new Error("Trying to add a disposable to a DisposableMap that has already been disposed of. The added object will be leaked!").stack),i||this._store.get(e)?.dispose(),this._store.set(e,t)}deleteAndDispose(e){this._store.get(e)?.dispose(),this._store.delete(e)}deleteAndLeak(e){const t=this._store.get(e);return this._store.delete(e),t}keys(){return this._store.keys()}values(){return this._store.values()}[Symbol.iterator](){return this._store[Symbol.iterator]()}}t.DisposableMap=S},6317:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.LinkedList=void 0;class i{static{this.Undefined=new i(void 0)}constructor(e){this.element=e,this.next=i.Undefined,this.prev=i.Undefined}}class s{constructor(){this._first=i.Undefined,this._last=i.Undefined,this._size=0}get size(){return this._size}isEmpty(){return this._first===i.Undefined}clear(){let e=this._first;for(;e!==i.Undefined;){const t=e.next;e.prev=i.Undefined,e.next=i.Undefined,e=t}this._first=i.Undefined,this._last=i.Undefined,this._size=0}unshift(e){return this._insert(e,!1)}push(e){return this._insert(e,!0)}_insert(e,t){const s=new i(e);if(this._first===i.Undefined)this._first=s,this._last=s;else if(t){const e=this._last;this._last=s,s.prev=e,e.next=s}else{const e=this._first;this._first=s,s.next=e,e.prev=s}this._size+=1;let r=!1;return()=>{r||(r=!0,this._remove(s))}}shift(){if(this._first!==i.Undefined){const e=this._first.element;return this._remove(this._first),e}}pop(){if(this._last!==i.Undefined){const e=this._last.element;return this._remove(this._last),e}}_remove(e){if(e.prev!==i.Undefined&&e.next!==i.Undefined){const t=e.prev;t.next=e.next,e.next.prev=t}else e.prev===i.Undefined&&e.next===i.Undefined?(this._first=i.Undefined,this._last=i.Undefined):e.next===i.Undefined?(this._last=this._last.prev,this._last.next=i.Undefined):e.prev===i.Undefined&&(this._first=this._first.next,this._first.prev=i.Undefined);this._size-=1}*[Symbol.iterator](){let e=this._first;for(;e!==i.Undefined;)yield e.element,e=e.next}}t.LinkedList=s},2608:(e,t)=>{var i;Object.defineProperty(t,"__esModule",{value:!0}),t.SetMap=t.BidirectionalMap=t.CounterSet=t.Touch=void 0,t.getOrSet=function(e,t,i){let s=e.get(t);return void 0===s&&(s=i,e.set(t,s)),s},t.mapToString=function(e){const t=[];return e.forEach(((e,i)=>{t.push(`${i} => ${e}`)})),`Map(${e.size}) {${t.join(", ")}}`},t.setToString=function(e){const t=[];return e.forEach((e=>{t.push(e)})),`Set(${e.size}) {${t.join(", ")}}`},t.mapsStrictEqualIgnoreOrder=function(e,t){if(e===t)return!0;if(e.size!==t.size)return!1;for(const[i,s]of e)if(!t.has(i)||t.get(i)!==s)return!1;for(const[i]of t)if(!e.has(i))return!1;return!0},function(e){e[e.None=0]="None",e[e.AsOld=1]="AsOld",e[e.AsNew=2]="AsNew"}(i||(t.Touch=i={})),t.CounterSet=class{constructor(){this.map=new Map}add(e){return this.map.set(e,(this.map.get(e)||0)+1),this}delete(e){let t=this.map.get(e)||0;return 0!==t&&(t--,0===t?this.map.delete(e):this.map.set(e,t),!0)}has(e){return this.map.has(e)}},t.BidirectionalMap=class{constructor(e){if(this._m1=new Map,this._m2=new Map,e)for(const[t,i]of e)this.set(t,i)}clear(){this._m1.clear(),this._m2.clear()}set(e,t){this._m1.set(e,t),this._m2.set(t,e)}get(e){return this._m1.get(e)}getKey(e){return this._m2.get(e)}delete(e){const t=this._m1.get(e);return void 0!==t&&(this._m1.delete(e),this._m2.delete(t),!0)}forEach(e,t){this._m1.forEach(((i,s)=>{e.call(t,i,s,this)}))}keys(){return this._m1.keys()}values(){return this._m1.values()}},t.SetMap=class{constructor(){this.map=new Map}add(e,t){let i=this.map.get(e);i||(i=new Set,this.map.set(e,i)),i.add(t)}delete(e,t){const i=this.map.get(e);i&&(i.delete(t),0===i.size&&this.map.delete(e))}forEach(e,t){const i=this.map.get(e);i&&i.forEach(t)}get(e){return this.map.get(e)||new Set}}},7704:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SlidingWindowAverage=t.MovingAverage=t.Counter=void 0,t.clamp=function(e,t,i){return Math.min(Math.max(e,t),i)},t.rot=function(e,t){return(t+e%t)%t},t.isPointWithinTriangle=function(e,t,i,s,r,n,o,a){const l=o-i,h=a-s,c=r-i,d=n-s,u=e-i,_=t-s,f=l*l+h*h,p=l*c+h*d,g=l*u+h*_,m=c*c+d*d,v=c*u+d*_,S=1/(f*m-p*p),b=(m*g-p*v)*S,C=(f*v-p*g)*S;return b>=0&&C>=0&&b+C<1},t.Counter=class{constructor(){this._next=0}getNext(){return this._next++}},t.MovingAverage=class{constructor(){this._n=1,this._val=0}update(e){return this._val=this._val+(e-this._val)/this._n,this._n+=1,this._val}get value(){return this._val}},t.SlidingWindowAverage=class{constructor(e){this._n=0,this._val=0,this._values=[],this._index=0,this._sum=0,this._values=new Array(e),this._values.fill(0,0,e)}update(e){const t=this._values[this._index];return this._values[this._index]=e,this._index=(this._index+1)%this._values.length,this._sum-=t,this._sum+=e,this._n{Object.defineProperty(t,"__esModule",{value:!0}),t.isAndroid=t.isEdge=t.isSafari=t.isFirefox=t.isChrome=t.OS=t.OperatingSystem=t.setTimeout0=t.setTimeout0IsFaster=t.translationsConfigFile=t.platformLocale=t.locale=t.Language=t.language=t.userAgent=t.platform=t.isCI=t.isMobile=t.isIOS=t.webWorkerOrigin=t.isWebWorker=t.isWeb=t.isElectron=t.isNative=t.isLinuxSnap=t.isLinux=t.isMacintosh=t.isWindows=t.Platform=t.LANGUAGE_DEFAULT=void 0,t.PlatformToString=function(e){switch(e){case C.Web:return"Web";case C.Mac:return"Mac";case C.Linux:return"Linux";case C.Windows:return"Windows"}},t.isLittleEndian=function(){if(!L){L=!0;const e=new Uint8Array(2);e[0]=1,e[1]=2;const t=new Uint16Array(e.buffer);D=513===t[0]}return D},t.isBigSurOrNewer=function(e){return parseFloat(e)>=20},t.LANGUAGE_DEFAULT="en";let i,s,r,n=!1,o=!1,a=!1,l=!1,h=!1,c=!1,d=!1,u=!1,_=!1,f=!1,p=t.LANGUAGE_DEFAULT,g=t.LANGUAGE_DEFAULT;const m=globalThis;let v;void 0!==m.vscode&&void 0!==m.vscode.process?v=m.vscode.process:"undefined"!=typeof process&&"string"==typeof process?.versions?.node&&(v=process);const S="string"==typeof v?.versions?.electron,b=S&&"renderer"===v?.type;if("object"==typeof v){n="win32"===v.platform,o="darwin"===v.platform,a="linux"===v.platform,l=a&&!!v.env.SNAP&&!!v.env.SNAP_REVISION,d=S,_=!!v.env.CI||!!v.env.BUILD_ARTIFACTSTAGINGDIRECTORY,i=t.LANGUAGE_DEFAULT,p=t.LANGUAGE_DEFAULT;const e=v.env.VSCODE_NLS_CONFIG;if(e)try{const r=JSON.parse(e);i=r.userLocale,g=r.osLocale,p=r.resolvedLanguage||t.LANGUAGE_DEFAULT,s=r.languagePack?.translationsConfigFile}catch(e){}h=!0}else"object"!=typeof navigator||b?console.error("Unable to resolve platform."):(r=navigator.userAgent,n=r.indexOf("Windows")>=0,o=r.indexOf("Macintosh")>=0,u=(r.indexOf("Macintosh")>=0||r.indexOf("iPad")>=0||r.indexOf("iPhone")>=0)&&!!navigator.maxTouchPoints&&navigator.maxTouchPoints>0,a=r.indexOf("Linux")>=0,f=r?.indexOf("Mobi")>=0,c=!0,p=globalThis._VSCODE_NLS_LANGUAGE||t.LANGUAGE_DEFAULT,i=navigator.language.toLowerCase(),g=i);var C;!function(e){e[e.Web=0]="Web",e[e.Mac=1]="Mac",e[e.Linux=2]="Linux",e[e.Windows=3]="Windows"}(C||(t.Platform=C={}));let y=C.Web;var w,E;o?y=C.Mac:n?y=C.Windows:a&&(y=C.Linux),t.isWindows=n,t.isMacintosh=o,t.isLinux=a,t.isLinuxSnap=l,t.isNative=h,t.isElectron=d,t.isWeb=c,t.isWebWorker=c&&"function"==typeof m.importScripts,t.webWorkerOrigin=t.isWebWorker?m.origin:void 0,t.isIOS=u,t.isMobile=f,t.isCI=_,t.platform=y,t.userAgent=r,t.language=p,function(e){e.value=function(){return t.language},e.isDefaultVariant=function(){return 2===t.language.length?"en"===t.language:t.language.length>=3&&"e"===t.language[0]&&"n"===t.language[1]&&"-"===t.language[2]},e.isDefault=function(){return"en"===t.language}}(w||(t.Language=w={})),t.locale=i,t.platformLocale=g,t.translationsConfigFile=s,t.setTimeout0IsFaster="function"==typeof m.postMessage&&!m.importScripts,t.setTimeout0=(()=>{if(t.setTimeout0IsFaster){const e=[];m.addEventListener("message",(t=>{if(t.data&&t.data.vscodeScheduleAsyncWork)for(let i=0,s=e.length;i{const s=++t;e.push({id:s,callback:i}),m.postMessage({vscodeScheduleAsyncWork:s},"*")}}return e=>setTimeout(e)})(),function(e){e[e.Windows=1]="Windows",e[e.Macintosh=2]="Macintosh",e[e.Linux=3]="Linux"}(E||(t.OperatingSystem=E={})),t.OS=o||u?E.Macintosh:n?E.Windows:E.Linux;let D=!0,L=!1;t.isChrome=!!(t.userAgent&&t.userAgent.indexOf("Chrome")>=0),t.isFirefox=!!(t.userAgent&&t.userAgent.indexOf("Firefox")>=0),t.isSafari=!!(!t.isChrome&&t.userAgent&&t.userAgent.indexOf("Safari")>=0),t.isEdge=!!(t.userAgent&&t.userAgent.indexOf("Edg/")>=0),t.isAndroid=!!(t.userAgent&&t.userAgent.indexOf("Android")>=0)},9881:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SmoothScrollingOperation=t.SmoothScrollingUpdate=t.Scrollable=t.ScrollState=t.ScrollbarVisibility=void 0;const s=i(802),r=i(7150);var n;!function(e){e[e.Auto=1]="Auto",e[e.Hidden=2]="Hidden",e[e.Visible=3]="Visible"}(n||(t.ScrollbarVisibility=n={}));class o{constructor(e,t,i,s,r,n,o){this._forceIntegerValues=e,this._scrollStateBrand=void 0,this._forceIntegerValues&&(t|=0,i|=0,s|=0,r|=0,n|=0,o|=0),this.rawScrollLeft=s,this.rawScrollTop=o,t<0&&(t=0),s+t>i&&(s=i-t),s<0&&(s=0),r<0&&(r=0),o+r>n&&(o=n-r),o<0&&(o=0),this.width=t,this.scrollWidth=i,this.scrollLeft=s,this.height=r,this.scrollHeight=n,this.scrollTop=o}equals(e){return this.rawScrollLeft===e.rawScrollLeft&&this.rawScrollTop===e.rawScrollTop&&this.width===e.width&&this.scrollWidth===e.scrollWidth&&this.scrollLeft===e.scrollLeft&&this.height===e.height&&this.scrollHeight===e.scrollHeight&&this.scrollTop===e.scrollTop}withScrollDimensions(e,t){return new o(this._forceIntegerValues,void 0!==e.width?e.width:this.width,void 0!==e.scrollWidth?e.scrollWidth:this.scrollWidth,t?this.rawScrollLeft:this.scrollLeft,void 0!==e.height?e.height:this.height,void 0!==e.scrollHeight?e.scrollHeight:this.scrollHeight,t?this.rawScrollTop:this.scrollTop)}withScrollPosition(e){return new o(this._forceIntegerValues,this.width,this.scrollWidth,void 0!==e.scrollLeft?e.scrollLeft:this.rawScrollLeft,this.height,this.scrollHeight,void 0!==e.scrollTop?e.scrollTop:this.rawScrollTop)}createScrollEvent(e,t){const i=this.width!==e.width,s=this.scrollWidth!==e.scrollWidth,r=this.scrollLeft!==e.scrollLeft,n=this.height!==e.height,o=this.scrollHeight!==e.scrollHeight,a=this.scrollTop!==e.scrollTop;return{inSmoothScrolling:t,oldWidth:e.width,oldScrollWidth:e.scrollWidth,oldScrollLeft:e.scrollLeft,width:this.width,scrollWidth:this.scrollWidth,scrollLeft:this.scrollLeft,oldHeight:e.height,oldScrollHeight:e.scrollHeight,oldScrollTop:e.scrollTop,height:this.height,scrollHeight:this.scrollHeight,scrollTop:this.scrollTop,widthChanged:i,scrollWidthChanged:s,scrollLeftChanged:r,heightChanged:n,scrollHeightChanged:o,scrollTopChanged:a}}}t.ScrollState=o;class a extends r.Disposable{constructor(e){super(),this._scrollableBrand=void 0,this._onScroll=this._register(new s.Emitter),this.onScroll=this._onScroll.event,this._smoothScrollDuration=e.smoothScrollDuration,this._scheduleAtNextAnimationFrame=e.scheduleAtNextAnimationFrame,this._state=new o(e.forceIntegerValues,0,0,0,0,0,0),this._smoothScrolling=null}dispose(){this._smoothScrolling&&(this._smoothScrolling.dispose(),this._smoothScrolling=null),super.dispose()}setSmoothScrollDuration(e){this._smoothScrollDuration=e}validateScrollPosition(e){return this._state.withScrollPosition(e)}getScrollDimensions(){return this._state}setScrollDimensions(e,t){const i=this._state.withScrollDimensions(e,t);this._setState(i,Boolean(this._smoothScrolling)),this._smoothScrolling?.acceptScrollDimensions(this._state)}getFutureScrollPosition(){return this._smoothScrolling?this._smoothScrolling.to:this._state}getCurrentScrollPosition(){return this._state}setScrollPositionNow(e){const t=this._state.withScrollPosition(e);this._smoothScrolling&&(this._smoothScrolling.dispose(),this._smoothScrolling=null),this._setState(t,!1)}setScrollPositionSmooth(e,t){if(0===this._smoothScrollDuration)return this.setScrollPositionNow(e);if(this._smoothScrolling){e={scrollLeft:void 0===e.scrollLeft?this._smoothScrolling.to.scrollLeft:e.scrollLeft,scrollTop:void 0===e.scrollTop?this._smoothScrolling.to.scrollTop:e.scrollTop};const i=this._state.withScrollPosition(e);if(this._smoothScrolling.to.scrollLeft===i.scrollLeft&&this._smoothScrolling.to.scrollTop===i.scrollTop)return;let s;s=t?new c(this._smoothScrolling.from,i,this._smoothScrolling.startTime,this._smoothScrolling.duration):this._smoothScrolling.combine(this._state,i,this._smoothScrollDuration),this._smoothScrolling.dispose(),this._smoothScrolling=s}else{const t=this._state.withScrollPosition(e);this._smoothScrolling=c.start(this._state,t,this._smoothScrollDuration)}this._smoothScrolling.animationFrameDisposable=this._scheduleAtNextAnimationFrame((()=>{this._smoothScrolling&&(this._smoothScrolling.animationFrameDisposable=null,this._performSmoothScrolling())}))}hasPendingScrollAnimation(){return Boolean(this._smoothScrolling)}_performSmoothScrolling(){if(!this._smoothScrolling)return;const e=this._smoothScrolling.tick(),t=this._state.withScrollPosition(e);return this._setState(t,!0),this._smoothScrolling?e.isDone?(this._smoothScrolling.dispose(),void(this._smoothScrolling=null)):void(this._smoothScrolling.animationFrameDisposable=this._scheduleAtNextAnimationFrame((()=>{this._smoothScrolling&&(this._smoothScrolling.animationFrameDisposable=null,this._performSmoothScrolling())}))):void 0}_setState(e,t){const i=this._state;i.equals(e)||(this._state=e,this._onScroll.fire(this._state.createScrollEvent(i,t)))}}t.Scrollable=a;class l{constructor(e,t,i){this.scrollLeft=e,this.scrollTop=t,this.isDone=i}}function h(e,t){const i=t-e;return function(t){return e+i*(1-(s=1-t,Math.pow(s,3)));var s}}t.SmoothScrollingUpdate=l;class c{constructor(e,t,i,s){this.from=e,this.to=t,this.duration=s,this.startTime=i,this.animationFrameDisposable=null,this._initAnimations()}_initAnimations(){this.scrollLeft=this._initAnimation(this.from.scrollLeft,this.to.scrollLeft,this.to.width),this.scrollTop=this._initAnimation(this.from.scrollTop,this.to.scrollTop,this.to.height)}_initAnimation(e,t,i){if(Math.abs(e-t)>2.5*i){let o,a;return e{Object.defineProperty(t,"__esModule",{value:!0}),t.StopWatch=void 0;const i=globalThis.performance&&"function"==typeof globalThis.performance.now;class s{static create(e){return new s(e)}constructor(e){this._now=i&&!1===e?Date.now:globalThis.performance.now.bind(globalThis.performance),this._startTime=this._now(),this._stopTime=-1}stop(){this._stopTime=this._now()}reset(){this._startTime=this._now(),this._stopTime=-1}elapsed(){return-1!==this._stopTime?this._stopTime-this._startTime:this._now()-this._startTime}}t.StopWatch=s},1316:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.noBreakWhitespace=t.CodePointIterator=void 0,t.isFalsyOrWhitespace=function(e){return!e||"string"!=typeof e||0===e.trim().length},t.format=function(e,...t){return 0===t.length?e:e.replace(n,(function(e,i){const s=parseInt(i,10);return isNaN(s)||s<0||s>=t.length?e:t[s]}))},t.format2=function(e,t){return 0===Object.keys(t).length?e:e.replace(o,((e,i)=>t[i]??e))},t.htmlAttributeEncodeValue=function(e){return e.replace(/[<>"'&]/g,(e=>{switch(e){case"<":return"<";case">":return">";case'"':return""";case"'":return"'";case"&":return"&"}return e}))},t.escape=function(e){return e.replace(/[<>&]/g,(function(e){switch(e){case"<":return"<";case">":return">";case"&":return"&";default:return e}}))},t.escapeRegExpCharacters=a,t.count=function(e,t){let i=0,s=e.indexOf(t);for(;-1!==s;)i++,s=e.indexOf(t,s+t.length);return i},t.truncate=function(e,t,i="…"){return e.length<=t?e:`${e.substr(0,t)}${i}`},t.truncateMiddle=function(e,t,i="…"){if(e.length<=t)return e;const s=Math.ceil(t/2)-i.length/2,r=Math.floor(t/2)-i.length/2;return`${e.substr(0,s)}${i}${e.substr(e.length-r)}`},t.trim=function(e,t=" "){return h(l(e,t),t)},t.ltrim=l,t.rtrim=h,t.convertSimple2RegExpPattern=function(e){return e.replace(/[\-\\\{\}\+\?\|\^\$\.\,\[\]\(\)\#\s]/g,"\\$&").replace(/[\*]/g,".*")},t.stripWildcards=function(e){return e.replace(/\*/g,"")},t.createRegExp=function(e,t,i={}){if(!e)throw new Error("Cannot create regex from empty string");t||(e=a(e)),i.wholeWord&&(/\B/.test(e.charAt(0))||(e="\\b"+e),/\B/.test(e.charAt(e.length-1))||(e+="\\b"));let s="";return i.global&&(s+="g"),i.matchCase||(s+="i"),i.multiline&&(s+="m"),i.unicode&&(s+="u"),new RegExp(e,s)},t.regExpLeadsToEndlessLoop=function(e){return"^"!==e.source&&"^$"!==e.source&&"$"!==e.source&&"^\\s*$"!==e.source&&!(!e.exec("")||0!==e.lastIndex)},t.splitLines=function(e){return e.split(/\r\n|\r|\n/)},t.splitLinesIncludeSeparators=function(e){const t=[],i=e.split(/(\r\n|\r|\n)/);for(let e=0;e=0;i--){const t=e.charCodeAt(i);if(t!==s.CharCode.Space&&t!==s.CharCode.Tab)return i}return-1},t.replaceAsync=function(e,t,i){const s=[];let r=0;for(const n of e.matchAll(t)){if(s.push(e.slice(r,n.index)),void 0===n.index)throw new Error("match.index should be defined");r=n.index+n[0].length,s.push(i(n[0],...n.slice(1),n.index,e,n.groups))}return s.push(e.slice(r)),Promise.all(s).then((e=>e.join("")))},t.compare=function(e,t){return et?1:0},t.compareSubstring=c,t.compareIgnoreCase=function(e,t){return d(e,t,0,e.length,0,t.length)},t.compareSubstringIgnoreCase=d,t.isAsciiDigit=function(e){return e>=s.CharCode.Digit0&&e<=s.CharCode.Digit9},t.isLowerAsciiLetter=u,t.isUpperAsciiLetter=function(e){return e>=s.CharCode.A&&e<=s.CharCode.Z},t.equalsIgnoreCase=function(e,t){return e.length===t.length&&0===d(e,t)},t.startsWithIgnoreCase=function(e,t){const i=t.length;return!(t.length>e.length)&&0===d(e,t,0,i)},t.commonPrefixLength=function(e,t){const i=Math.min(e.length,t.length);let s;for(s=0;sn)return 1}const o=s-i,a=n-r;return oa?1:0}function d(e,t,i=0,s=e.length,r=0,n=t.length){for(;i=128||a>=128)return c(e.toLowerCase(),t.toLowerCase(),i,s,r,n);u(o)&&(o-=32),u(a)&&(a-=32);const l=o-a;if(0!==l)return l}const o=s-i,a=n-r;return oa?1:0}function u(e){return e>=s.CharCode.a&&e<=s.CharCode.z}function _(e){return 55296<=e&&e<=56319}function f(e){return 56320<=e&&e<=57343}function p(e,t){return t-56320+(e-55296<<10)+65536}function g(e,t,i){const s=e.charCodeAt(i);if(_(s)&&i+11){const s=e.charCodeAt(t-2);if(_(s))return p(s,i)}return i}(this._str,this._offset);return this._offset-=e>=r.Constants.UNICODE_SUPPLEMENTARY_PLANE_BEGIN?2:1,e}nextCodePoint(){const e=g(this._str,this._len,this._offset);return this._offset+=e>=r.Constants.UNICODE_SUPPLEMENTARY_PLANE_BEGIN?2:1,e}eol(){return this._offset>=this._len}},t.noBreakWhitespace=" "},5015:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.MicrotaskDelay=void 0,t.MicrotaskDelay=Symbol("MicrotaskDelay")},8960:(e,t)=>{var i;Object.defineProperty(t,"__esModule",{value:!0}),t.Constants=void 0,t.toUint8=function(e){return e<0?0:e>i.MAX_UINT_8?i.MAX_UINT_8:0|e},t.toUint32=function(e){return e<0?0:e>i.MAX_UINT_32?i.MAX_UINT_32:0|e},function(e){e[e.MAX_SAFE_SMALL_INTEGER=1073741824]="MAX_SAFE_SMALL_INTEGER",e[e.MIN_SAFE_SMALL_INTEGER=-1073741824]="MIN_SAFE_SMALL_INTEGER",e[e.MAX_UINT_8=255]="MAX_UINT_8",e[e.MAX_UINT_16=65535]="MAX_UINT_16",e[e.MAX_UINT_32=4294967295]="MAX_UINT_32",e[e.UNICODE_SUPPLEMENTARY_PLANE_BEGIN=65536]="UNICODE_SUPPLEMENTARY_PLANE_BEGIN"}(i||(t.Constants=i={}))}},t={};function i(s){var r=t[s];if(void 0!==r)return r.exports;var n=t[s]={exports:{}};return e[s].call(n.exports,n,n.exports,i),n.exports}var s={};return(()=>{var e=s;Object.defineProperty(e,"__esModule",{value:!0}),e.Terminal=void 0;const t=i(7721),r=i(1718),n=i(7150),o=i(3027),a=i(5101),l=i(6097),h=i(4335),c=["cols","rows"];let d=0;class u extends n.Disposable{constructor(e){super(),this._core=this._register(new r.CoreBrowserTerminal(e)),this._addonManager=this._register(new o.AddonManager),this._publicOptions={...this._core.options};const t=e=>this._core.options[e],i=(e,t)=>{this._checkReadonlyOptions(e),this._core.options[e]=t};for(const e in this._core.options){const s={get:t.bind(this,e),set:i.bind(this,e)};Object.defineProperty(this._publicOptions,e,s)}}_checkReadonlyOptions(e){if(c.includes(e))throw new Error(`Option "${e}" can only be set in the constructor`)}_checkProposedApi(){if(!this._core.optionsService.rawOptions.allowProposedApi)throw new Error("You must set the allowProposedApi option to true to use proposed API")}get onBell(){return this._core.onBell}get onBinary(){return this._core.onBinary}get onCursorMove(){return this._core.onCursorMove}get onData(){return this._core.onData}get onKey(){return this._core.onKey}get onLineFeed(){return this._core.onLineFeed}get onRender(){return this._core.onRender}get onResize(){return this._core.onResize}get onScroll(){return this._core.onScroll}get onSelectionChange(){return this._core.onSelectionChange}get onTitleChange(){return this._core.onTitleChange}get onWriteParsed(){return this._core.onWriteParsed}get element(){return this._core.element}get parser(){return this._parser||(this._parser=new l.ParserApi(this._core)),this._parser}get unicode(){return this._checkProposedApi(),new h.UnicodeApi(this._core)}get textarea(){return this._core.textarea}get rows(){return this._core.rows}get cols(){return this._core.cols}get buffer(){return this._buffer||(this._buffer=this._register(new a.BufferNamespaceApi(this._core))),this._buffer}get markers(){return this._checkProposedApi(),this._core.markers}get modes(){const e=this._core.coreService.decPrivateModes;let t="none";switch(this._core.coreMouseService.activeProtocol){case"X10":t="x10";break;case"VT200":t="vt200";break;case"DRAG":t="drag";break;case"ANY":t="any"}return{applicationCursorKeysMode:e.applicationCursorKeys,applicationKeypadMode:e.applicationKeypad,bracketedPasteMode:e.bracketedPasteMode,insertMode:this._core.coreService.modes.insertMode,mouseTrackingMode:t,originMode:e.origin,reverseWraparoundMode:e.reverseWraparound,sendFocusMode:e.sendFocus,synchronizedOutputMode:e.synchronizedOutput,wraparoundMode:e.wraparound}}get options(){return this._publicOptions}set options(e){for(const t in e)this._publicOptions[t]=e[t]}blur(){this._core.blur()}focus(){this._core.focus()}input(e,t=!0){this._core.input(e,t)}resize(e,t){this._verifyIntegers(e,t),this._core.resize(e,t)}open(e){this._core.open(e)}attachCustomKeyEventHandler(e){this._core.attachCustomKeyEventHandler(e)}attachCustomWheelEventHandler(e){this._core.attachCustomWheelEventHandler(e)}registerLinkProvider(e){return this._core.registerLinkProvider(e)}registerCharacterJoiner(e){return this._checkProposedApi(),this._core.registerCharacterJoiner(e)}deregisterCharacterJoiner(e){this._checkProposedApi(),this._core.deregisterCharacterJoiner(e)}registerMarker(e=0){return this._verifyIntegers(e),this._core.registerMarker(e)}registerDecoration(e){return this._checkProposedApi(),this._verifyPositiveIntegers(e.x??0,e.width??0,e.height??0),this._core.registerDecoration(e)}hasSelection(){return this._core.hasSelection()}select(e,t,i){this._verifyIntegers(e,t,i),this._core.select(e,t,i)}getSelection(){return this._core.getSelection()}getSelectionPosition(){return this._core.getSelectionPosition()}clearSelection(){this._core.clearSelection()}selectAll(){this._core.selectAll()}selectLines(e,t){this._verifyIntegers(e,t),this._core.selectLines(e,t)}dispose(){super.dispose()}scrollLines(e){this._verifyIntegers(e),this._core.scrollLines(e)}scrollPages(e){this._verifyIntegers(e),this._core.scrollPages(e)}scrollToTop(){this._core.scrollToTop()}scrollToBottom(){this._core.scrollToBottom()}scrollToLine(e){this._verifyIntegers(e),this._core.scrollToLine(e)}clear(){this._core.clear()}write(e,t){this._core.write(e,t)}writeln(e,t){this._core.write(e),this._core.write("\r\n",t)}paste(e){this._core.paste(e)}refresh(e,t){this._verifyIntegers(e,t),this._core.refresh(e,t)}reset(){this._core.reset()}clearTextureAtlas(){this._core.clearTextureAtlas()}loadAddon(e){this._addonManager.loadAddon(this,e)}static get strings(){return{get promptLabel(){return t.promptLabel.get()},set promptLabel(e){t.promptLabel.set(e)},get tooMuchOutput(){return t.tooMuchOutput.get()},set tooMuchOutput(e){t.tooMuchOutput.set(e)}}}_verifyIntegers(...e){for(d of e)if(d===1/0||isNaN(d)||d%1!=0)throw new Error("This API only accepts integers")}_verifyPositiveIntegers(...e){for(d of e)if(d&&(d===1/0||isNaN(d)||d%1!=0||d<0))throw new Error("This API only accepts positive integers")}}e.Terminal=u})(),s})())); +!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(globalThis,(()=>(()=>{"use strict";var e={2840:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AccessibilityManager=void 0;const n=i(7721),o=i(4292),a=i(7150),l=i(7098),h=i(6501),c=i(7093);let d=class extends a.Disposable{constructor(e,t,i,s){super(),this._terminal=e,this._coreBrowserService=i,this._renderService=s,this._rowColumns=new WeakMap,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="";const r=this._coreBrowserService.mainDocument;this._accessibilityContainer=r.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=r.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let e=0;ethis._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=r.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this._register(new o.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this._register(this._terminal.onResize((e=>this._handleResize(e.rows)))),this._register(this._terminal.onRender((e=>this._refreshRows(e.start,e.end)))),this._register(this._terminal.onScroll((()=>this._refreshRows()))),this._register(this._terminal.onA11yChar((e=>this._handleChar(e)))),this._register(this._terminal.onLineFeed((()=>this._handleChar("\n")))),this._register(this._terminal.onA11yTab((e=>this._handleTab(e)))),this._register(this._terminal.onKey((e=>this._handleKey(e.key)))),this._register(this._terminal.onBlur((()=>this._clearLiveRegion()))),this._register(this._renderService.onDimensionsChange((()=>this._refreshRowsDimensions()))),this._register((0,c.addDisposableListener)(r,"selectionchange",(()=>this._handleSelectionChange()))),this._register(this._coreBrowserService.onDprChange((()=>this._refreshRowsDimensions()))),this._refreshRowsDimensions(),this._refreshRows(),this._register((0,a.toDisposable)((()=>{this._accessibilityContainer.remove(),this._rowElements.length=0})))}_handleTab(e){for(let t=0;t0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent+=n.tooMuchOutput.get())))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.lines.get(i.ydisp+r),t=[],n=e?.translateToString(!0,void 0,void 0,t)||"",o=(i.ydisp+r+1).toString(),a=this._rowElements[r];a&&(0===n.length?(a.textContent=" ",this._rowColumns.set(a,[0,1])):(a.textContent=n,this._rowColumns.set(a,t)),a.setAttribute("aria-posinset",o),a.setAttribute("aria-setsize",s),this._alignRowWidth(a))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,n;if(0===t?(r=i,n=this._rowElements.pop(),this._rowContainer.removeChild(n)):(r=this._rowElements.shift(),n=i,this._rowContainer.removeChild(r)),r.removeEventListener("focus",this._topBoundaryFocusListener),n.removeEventListener("focus",this._bottomBoundaryFocusListener),0===t){const e=this._createAccessibilityTreeNode();this._rowElements.unshift(e),this._rowContainer.insertAdjacentElement("afterbegin",e)}else{const e=this._createAccessibilityTreeNode();this._rowElements.push(e),this._rowContainer.appendChild(e)}this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._terminal.scrollLines(0===t?-1:1),this._rowElements[0===t?1:this._rowElements.length-2].focus(),e.preventDefault(),e.stopImmediatePropagation()}_handleSelectionChange(){if(0===this._rowElements.length)return;const e=this._coreBrowserService.mainDocument.getSelection();if(!e)return;if(e.isCollapsed)return void(this._rowContainer.contains(e.anchorNode)&&this._terminal.clearSelection());if(!e.anchorNode||!e.focusNode)return void console.error("anchorNode and/or focusNode are null");let t={node:e.anchorNode,offset:e.anchorOffset},i={node:e.focusNode,offset:e.focusOffset};if((t.node.compareDocumentPosition(i.node)&Node.DOCUMENT_POSITION_PRECEDING||t.node===i.node&&t.offset>i.offset)&&([t,i]=[i,t]),t.node.compareDocumentPosition(this._rowElements[0])&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_FOLLOWING)&&(t={node:this._rowElements[0].childNodes[0],offset:0}),!this._rowContainer.contains(t.node))return;const s=this._rowElements.slice(-1)[0];if(i.node.compareDocumentPosition(s)&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_PRECEDING)&&(i={node:s,offset:s.textContent?.length??0}),!this._rowContainer.contains(i.node))return;const r=({node:e,offset:t})=>{const i=e instanceof Text?e.parentNode:e;let s=parseInt(i?.getAttribute("aria-posinset"),10)-1;if(isNaN(s))return console.warn("row is invalid. Race condition?"),null;const r=this._rowColumns.get(i);if(!r)return console.warn("columns is null. Race condition?"),null;let n=t=this._terminal.cols&&(++s,n=0),{row:s,column:n}},n=r(t),o=r(i);if(n&&o){if(n.row>o.row||n.row===o.row&&n.column>=o.column)throw new Error("invalid range");this._terminal.select(n.column,n.row,(o.row-n.row)*this._terminal.cols-n.column+o.column)}}_handleResize(e){this._rowElements[this._rowElements.length-1].removeEventListener("focus",this._bottomBoundaryFocusListener);for(let e=this._rowContainer.children.length;ee;)this._rowContainer.removeChild(this._rowElements.pop());this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions()}_createAccessibilityTreeNode(){const e=this._coreBrowserService.mainDocument.createElement("div");return e.setAttribute("role","listitem"),e.tabIndex=-1,this._refreshRowDimensions(e),e}_refreshRowsDimensions(){if(this._renderService.dimensions.css.cell.height){Object.assign(this._accessibilityContainer.style,{width:`${this._renderService.dimensions.css.canvas.width}px`,fontSize:`${this._terminal.options.fontSize}px`}),this._rowElements.length!==this._terminal.rows&&this._handleResize(this._terminal.rows);for(let e=0;e{function i(e){return e.replace(/\r?\n/g,"\r")}function s(e,t){return t?"[200~"+e+"[201~":e}function r(e,t,r,n){e=s(e=i(e),r.decPrivateModes.bracketedPasteMode&&!0!==n.rawOptions.ignoreBracketedPasteMode),r.triggerDataEvent(e,!0),t.value=""}function n(e,t,i){const s=i.getBoundingClientRect(),r=e.clientX-s.left-10,n=e.clientY-s.top-10;t.style.width="20px",t.style.height="20px",t.style.left=`${r}px`,t.style.top=`${n}px`,t.style.zIndex="1000",t.focus()}Object.defineProperty(t,"__esModule",{value:!0}),t.prepareTextForTerminal=i,t.bracketTextForPaste=s,t.copyHandler=function(e,t){e.clipboardData&&e.clipboardData.setData("text/plain",t.selectionText),e.preventDefault()},t.handlePasteEvent=function(e,t,i,s){e.stopPropagation(),e.clipboardData&&r(e.clipboardData.getData("text/plain"),t,i,s)},t.paste=r,t.moveTextAreaUnderMouseCursor=n,t.rightClickHandler=function(e,t,i,s,r){n(e,t,i),r&&s.rightClickSelect(e),t.value=s.selectionText,t.select()}},7174:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ColorContrastCache=void 0;const s=i(7710);t.ColorContrastCache=class{constructor(){this._color=new s.TwoKeyMap,this._css=new s.TwoKeyMap}setCss(e,t,i){this._css.set(e,t,i)}getCss(e,t){return this._css.get(e,t)}setColor(e,t,i){this._color.set(e,t,i)}getColor(e,t){return this._color.get(e,t)}clear(){this._color.clear(),this._css.clear()}}},1718:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreBrowserTerminal=void 0;const s=i(7861),r=i(7721),n=i(3285),o=i(4017),a=i(4196),l=i(9925),h=i(3618),c=i(3955),d=i(4792),u=i(945),_=i(9574),f=i(9820),p=i(9784),g=i(5783),m=i(2079),v=i(7098),S=i(9078),b=i(4103),C=i(5777),y=i(701),w=i(6107),E=i(3534),D=i(706),L=i(8693),R=i(4720),A=i(6501),T=i(2486),k=i(2840),M=i(8906),O=i(802),I=i(7093),P=i(7150);class x extends C.CoreTerminal{get linkifier(){return this._linkifier.value}get onFocus(){return this._onFocus.event}get onBlur(){return this._onBlur.event}get onA11yChar(){return this._onA11yCharEmitter.event}get onA11yTab(){return this._onA11yTabEmitter.event}get onWillOpen(){return this._onWillOpen.event}constructor(e={}){super(e),this._linkifier=this._register(new P.MutableDisposable),this.browser=y,this._keyDownHandled=!1,this._keyDownSeen=!1,this._keyPressHandled=!1,this._unprocessedDeadKey=!1,this._accessibilityManager=this._register(new P.MutableDisposable),this._onCursorMove=this._register(new O.Emitter),this.onCursorMove=this._onCursorMove.event,this._onKey=this._register(new O.Emitter),this.onKey=this._onKey.event,this._onRender=this._register(new O.Emitter),this.onRender=this._onRender.event,this._onSelectionChange=this._register(new O.Emitter),this.onSelectionChange=this._onSelectionChange.event,this._onTitleChange=this._register(new O.Emitter),this.onTitleChange=this._onTitleChange.event,this._onBell=this._register(new O.Emitter),this.onBell=this._onBell.event,this._onFocus=this._register(new O.Emitter),this._onBlur=this._register(new O.Emitter),this._onA11yCharEmitter=this._register(new O.Emitter),this._onA11yTabEmitter=this._register(new O.Emitter),this._onWillOpen=this._register(new O.Emitter),this._setup(),this._decorationService=this._instantiationService.createInstance(R.DecorationService),this._instantiationService.setService(A.IDecorationService,this._decorationService),this._linkProviderService=this._instantiationService.createInstance(f.LinkProviderService),this._instantiationService.setService(v.ILinkProviderService,this._linkProviderService),this._linkProviderService.registerLinkProvider(this._instantiationService.createInstance(n.OscLinkProvider)),this._register(this._inputHandler.onRequestBell((()=>this._onBell.fire()))),this._register(this._inputHandler.onRequestRefreshRows((e=>this.refresh(e?.start??0,e?.end??this.rows-1)))),this._register(this._inputHandler.onRequestSendFocus((()=>this._reportFocus()))),this._register(this._inputHandler.onRequestReset((()=>this.reset()))),this._register(this._inputHandler.onRequestWindowsOptionsReport((e=>this._reportWindowsOptions(e)))),this._register(this._inputHandler.onColor((e=>this._handleColorEvent(e)))),this._register(O.Event.forward(this._inputHandler.onCursorMove,this._onCursorMove)),this._register(O.Event.forward(this._inputHandler.onTitleChange,this._onTitleChange)),this._register(O.Event.forward(this._inputHandler.onA11yChar,this._onA11yCharEmitter)),this._register(O.Event.forward(this._inputHandler.onA11yTab,this._onA11yTabEmitter)),this._register(this._bufferService.onResize((e=>this._afterResize(e.cols,e.rows)))),this._register((0,P.toDisposable)((()=>{this._customKeyEventHandler=void 0,this.element?.parentNode?.removeChild(this.element)})))}_handleColorEvent(e){if(this._themeService)for(const t of e){let e,i="";switch(t.index){case 256:e="foreground",i="10";break;case 257:e="background",i="11";break;case 258:e="cursor",i="12";break;default:e="ansi",i="4;"+t.index}switch(t.type){case 0:const s=b.color.toColorRGB("ansi"===e?this._themeService.colors.ansi[t.index]:this._themeService.colors[e]);this.coreService.triggerDataEvent(`${E.C0.ESC}]${i};${(0,L.toRgbString)(s)}${E.C1_ESCAPED.ST}`);break;case 1:if("ansi"===e)this._themeService.modifyColors((e=>e.ansi[t.index]=b.channels.toColor(...t.color)));else{const i=e;this._themeService.modifyColors((e=>e[i]=b.channels.toColor(...t.color)))}break;case 2:this._themeService.restoreColor(t.index)}}}_setup(){super._setup(),this._customKeyEventHandler=void 0}get buffer(){return this.buffers.active}focus(){this.textarea&&this.textarea.focus({preventScroll:!0})}_handleScreenReaderModeOptionChange(e){e?!this._accessibilityManager.value&&this._renderService&&(this._accessibilityManager.value=this._instantiationService.createInstance(k.AccessibilityManager,this)):this._accessibilityManager.clear()}_handleTextAreaFocus(e){this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(E.C0.ESC+"[I"),this.element.classList.add("focus"),this._showCursor(),this._onFocus.fire()}blur(){return this.textarea?.blur()}_handleTextAreaBlur(){this.textarea.value="",this.refresh(this.buffer.y,this.buffer.y),this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(E.C0.ESC+"[O"),this.element.classList.remove("focus"),this._onBlur.fire()}_syncTextArea(){if(!this.textarea||!this.buffer.isCursorInViewport||this._compositionHelper.isComposing||!this._renderService)return;const e=this.buffer.ybase+this.buffer.y,t=this.buffer.lines.get(e);if(!t)return;const i=Math.min(this.buffer.x,this.cols-1),s=this._renderService.dimensions.css.cell.height,r=t.getWidth(i),n=this._renderService.dimensions.css.cell.width*r,o=this.buffer.y*this._renderService.dimensions.css.cell.height,a=i*this._renderService.dimensions.css.cell.width;this.textarea.style.left=a+"px",this.textarea.style.top=o+"px",this.textarea.style.width=n+"px",this.textarea.style.height=s+"px",this.textarea.style.lineHeight=s+"px",this.textarea.style.zIndex="-5"}_initGlobal(){this._bindKeys(),this._register((0,I.addDisposableListener)(this.element,"copy",(e=>{this.hasSelection()&&(0,s.copyHandler)(e,this._selectionService)})));const e=e=>(0,s.handlePasteEvent)(e,this.textarea,this.coreService,this.optionsService);this._register((0,I.addDisposableListener)(this.textarea,"paste",e)),this._register((0,I.addDisposableListener)(this.element,"paste",e)),y.isFirefox?this._register((0,I.addDisposableListener)(this.element,"mousedown",(e=>{2===e.button&&(0,s.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)}))):this._register((0,I.addDisposableListener)(this.element,"contextmenu",(e=>{(0,s.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)}))),y.isLinux&&this._register((0,I.addDisposableListener)(this.element,"auxclick",(e=>{1===e.button&&(0,s.moveTextAreaUnderMouseCursor)(e,this.textarea,this.screenElement)})))}_bindKeys(){this._register((0,I.addDisposableListener)(this.textarea,"keyup",(e=>this._keyUp(e)),!0)),this._register((0,I.addDisposableListener)(this.textarea,"keydown",(e=>this._keyDown(e)),!0)),this._register((0,I.addDisposableListener)(this.textarea,"keypress",(e=>this._keyPress(e)),!0)),this._register((0,I.addDisposableListener)(this.textarea,"compositionstart",(()=>this._compositionHelper.compositionstart()))),this._register((0,I.addDisposableListener)(this.textarea,"compositionupdate",(e=>this._compositionHelper.compositionupdate(e)))),this._register((0,I.addDisposableListener)(this.textarea,"compositionend",(()=>this._compositionHelper.compositionend()))),this._register((0,I.addDisposableListener)(this.textarea,"input",(e=>this._inputEvent(e)),!0)),this._register(this.onRender((()=>this._compositionHelper.updateCompositionElements())))}open(e){if(!e)throw new Error("Terminal requires a parent element.");if(e.isConnected||this._logService.debug("Terminal.open was called on an element that was not attached to the DOM"),this.element?.ownerDocument.defaultView&&this._coreBrowserService)return void(this.element.ownerDocument.defaultView!==this._coreBrowserService.window&&(this._coreBrowserService.window=this.element.ownerDocument.defaultView));this._document=e.ownerDocument,this.options.documentOverride&&this.options.documentOverride instanceof Document&&(this._document=this.optionsService.rawOptions.documentOverride),this.element=this._document.createElement("div"),this.element.dir="ltr",this.element.classList.add("terminal"),this.element.classList.add("xterm"),e.appendChild(this.element);const t=this._document.createDocumentFragment();this._viewportElement=this._document.createElement("div"),this._viewportElement.classList.add("xterm-viewport"),t.appendChild(this._viewportElement),this.screenElement=this._document.createElement("div"),this.screenElement.classList.add("xterm-screen"),this._register((0,I.addDisposableListener)(this.screenElement,"mousemove",(e=>this.updateCursorStyle(e)))),this._helperContainer=this._document.createElement("div"),this._helperContainer.classList.add("xterm-helpers"),this.screenElement.appendChild(this._helperContainer),t.appendChild(this.screenElement);const i=this.textarea=this._document.createElement("textarea");this.textarea.classList.add("xterm-helper-textarea"),this.textarea.setAttribute("aria-label",r.promptLabel.get()),y.isChromeOS||this.textarea.setAttribute("aria-multiline","false"),this.textarea.setAttribute("autocorrect","off"),this.textarea.setAttribute("autocapitalize","off"),this.textarea.setAttribute("spellcheck","false"),this.textarea.tabIndex=0,this._register(this.optionsService.onSpecificOptionChange("disableStdin",(()=>i.readOnly=this.optionsService.rawOptions.disableStdin))),this.textarea.readOnly=this.optionsService.rawOptions.disableStdin,this._coreBrowserService=this._register(this._instantiationService.createInstance(_.CoreBrowserService,this.textarea,e.ownerDocument.defaultView??window,this._document??"undefined"!=typeof window?window.document:null)),this._instantiationService.setService(v.ICoreBrowserService,this._coreBrowserService),this._register((0,I.addDisposableListener)(this.textarea,"focus",(e=>this._handleTextAreaFocus(e)))),this._register((0,I.addDisposableListener)(this.textarea,"blur",(()=>this._handleTextAreaBlur()))),this._helperContainer.appendChild(this.textarea),this._charSizeService=this._instantiationService.createInstance(d.CharSizeService,this._document,this._helperContainer),this._instantiationService.setService(v.ICharSizeService,this._charSizeService),this._themeService=this._instantiationService.createInstance(S.ThemeService),this._instantiationService.setService(v.IThemeService,this._themeService),this._characterJoinerService=this._instantiationService.createInstance(u.CharacterJoinerService),this._instantiationService.setService(v.ICharacterJoinerService,this._characterJoinerService),this._renderService=this._register(this._instantiationService.createInstance(g.RenderService,this.rows,this.screenElement)),this._instantiationService.setService(v.IRenderService,this._renderService),this._register(this._renderService.onRenderedViewportChange((e=>this._onRender.fire(e)))),this.onResize((e=>this._renderService.resize(e.cols,e.rows))),this._compositionView=this._document.createElement("div"),this._compositionView.classList.add("composition-view"),this._compositionHelper=this._instantiationService.createInstance(h.CompositionHelper,this.textarea,this._compositionView),this._helperContainer.appendChild(this._compositionView),this._mouseService=this._instantiationService.createInstance(p.MouseService),this._instantiationService.setService(v.IMouseService,this._mouseService);const s=this._linkifier.value=this._register(this._instantiationService.createInstance(M.Linkifier,this.screenElement));this.element.appendChild(t);try{this._onWillOpen.fire(this.element)}catch{}this._renderService.hasRenderer()||this._renderService.setRenderer(this._createRenderer()),this._register(this.onCursorMove((()=>{this._renderService.handleCursorMove(),this._syncTextArea()}))),this._register(this.onResize((()=>this._renderService.handleResize(this.cols,this.rows)))),this._register(this.onBlur((()=>this._renderService.handleBlur()))),this._register(this.onFocus((()=>this._renderService.handleFocus()))),this._viewport=this._register(this._instantiationService.createInstance(o.Viewport,this.element,this.screenElement)),this._register(this._viewport.onRequestScrollLines((e=>{super.scrollLines(e,!1),this.refresh(0,this.rows-1)}))),this._selectionService=this._register(this._instantiationService.createInstance(m.SelectionService,this.element,this.screenElement,s)),this._instantiationService.setService(v.ISelectionService,this._selectionService),this._register(this._selectionService.onRequestScrollLines((e=>this.scrollLines(e.amount,e.suppressScrollEvent)))),this._register(this._selectionService.onSelectionChange((()=>this._onSelectionChange.fire()))),this._register(this._selectionService.onRequestRedraw((e=>this._renderService.handleSelectionChanged(e.start,e.end,e.columnSelectMode)))),this._register(this._selectionService.onLinuxMouseSelection((e=>{this.textarea.value=e,this.textarea.focus(),this.textarea.select()}))),this._register(O.Event.any(this._onScroll.event,this._inputHandler.onScroll)((()=>{this._selectionService.refresh(),this._viewport?.queueSync()}))),this._register(this._instantiationService.createInstance(a.BufferDecorationRenderer,this.screenElement)),this._register((0,I.addDisposableListener)(this.element,"mousedown",(e=>this._selectionService.handleMouseDown(e)))),this.coreMouseService.areMouseEventsActive?(this._selectionService.disable(),this.element.classList.add("enable-mouse-events")):this._selectionService.enable(),this.options.screenReaderMode&&(this._accessibilityManager.value=this._instantiationService.createInstance(k.AccessibilityManager,this)),this._register(this.optionsService.onSpecificOptionChange("screenReaderMode",(e=>this._handleScreenReaderModeOptionChange(e)))),this.options.overviewRuler.width&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(l.OverviewRulerRenderer,this._viewportElement,this.screenElement))),this.optionsService.onSpecificOptionChange("overviewRuler",(e=>{!this._overviewRulerRenderer&&e&&this._viewportElement&&this.screenElement&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(l.OverviewRulerRenderer,this._viewportElement,this.screenElement)))})),this._charSizeService.measure(),this.refresh(0,this.rows-1),this._initGlobal(),this.bindMouse()}_createRenderer(){return this._instantiationService.createInstance(c.DomRenderer,this,this._document,this.element,this.screenElement,this._viewportElement,this._helperContainer,this.linkifier)}bindMouse(){const e=this,t=this.element;function i(t){const i=e._mouseService.getMouseReportCoords(t,e.screenElement);if(!i)return!1;let s,r;switch(t.overrideType||t.type){case"mousemove":r=32,void 0===t.buttons?(s=3,void 0!==t.button&&(s=t.button<3?t.button:3)):s=1&t.buttons?0:4&t.buttons?1:2&t.buttons?2:3;break;case"mouseup":r=0,s=t.button<3?t.button:3;break;case"mousedown":r=1,s=t.button<3?t.button:3;break;case"wheel":if(e._customWheelEventHandler&&!1===e._customWheelEventHandler(t))return!1;const i=t.deltaY;if(0===i)return!1;if(0===e.coreMouseService.consumeWheelEvent(t,e._renderService?.dimensions?.device?.cell?.height,e._coreBrowserService?.dpr))return!1;r=i<0?0:1,s=4;break;default:return!1}return!(void 0===r||void 0===s||s>4)&&e.coreMouseService.triggerMouseEvent({col:i.col,row:i.row,x:i.x,y:i.y,button:s,action:r,ctrl:t.ctrlKey,alt:t.altKey,shift:t.shiftKey})}const s={mouseup:null,wheel:null,mousedrag:null,mousemove:null},r={mouseup:e=>(i(e),e.buttons||(this._document.removeEventListener("mouseup",s.mouseup),s.mousedrag&&this._document.removeEventListener("mousemove",s.mousedrag)),this.cancel(e)),wheel:e=>(i(e),this.cancel(e,!0)),mousedrag:e=>{e.buttons&&i(e)},mousemove:e=>{e.buttons||i(e)}};this._register(this.coreMouseService.onProtocolChange((e=>{e?("debug"===this.optionsService.rawOptions.logLevel&&this._logService.debug("Binding to mouse events:",this.coreMouseService.explainEvents(e)),this.element.classList.add("enable-mouse-events"),this._selectionService.disable()):(this._logService.debug("Unbinding from mouse events."),this.element.classList.remove("enable-mouse-events"),this._selectionService.enable()),8&e?s.mousemove||(t.addEventListener("mousemove",r.mousemove),s.mousemove=r.mousemove):(t.removeEventListener("mousemove",s.mousemove),s.mousemove=null),16&e?s.wheel||(t.addEventListener("wheel",r.wheel,{passive:!1}),s.wheel=r.wheel):(t.removeEventListener("wheel",s.wheel),s.wheel=null),2&e?s.mouseup||(s.mouseup=r.mouseup):(this._document.removeEventListener("mouseup",s.mouseup),s.mouseup=null),4&e?s.mousedrag||(s.mousedrag=r.mousedrag):(this._document.removeEventListener("mousemove",s.mousedrag),s.mousedrag=null)}))),this.coreMouseService.activeProtocol=this.coreMouseService.activeProtocol,this._register((0,I.addDisposableListener)(t,"mousedown",(e=>{if(e.preventDefault(),this.focus(),this.coreMouseService.areMouseEventsActive&&!this._selectionService.shouldForceSelection(e))return i(e),s.mouseup&&this._document.addEventListener("mouseup",s.mouseup),s.mousedrag&&this._document.addEventListener("mousemove",s.mousedrag),this.cancel(e)}))),this._register((0,I.addDisposableListener)(t,"wheel",(t=>{if(!s.wheel){if(this._customWheelEventHandler&&!1===this._customWheelEventHandler(t))return!1;if(!this.buffer.hasScrollback){if(0===t.deltaY)return!1;if(0===e.coreMouseService.consumeWheelEvent(t,e._renderService?.dimensions?.device?.cell?.height,e._coreBrowserService?.dpr))return this.cancel(t,!0);const i=E.C0.ESC+(this.coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(t.deltaY<0?"A":"B");return this.coreService.triggerDataEvent(i,!0),this.cancel(t,!0)}}}),{passive:!1}))}refresh(e,t){this._renderService?.refreshRows(e,t)}updateCursorStyle(e){this._selectionService?.shouldColumnSelect(e)?this.element.classList.add("column-select"):this.element.classList.remove("column-select")}_showCursor(){this.coreService.isCursorInitialized||(this.coreService.isCursorInitialized=!0,this.refresh(this.buffer.y,this.buffer.y))}scrollLines(e,t){this._viewport?this._viewport.scrollLines(e):super.scrollLines(e,t),this.refresh(0,this.rows-1)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){e&&this._viewport?this._viewport.scrollToLine(this.buffer.ybase,!0):this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){const t=e-this._bufferService.buffer.ydisp;0!==t&&this.scrollLines(t)}paste(e){(0,s.paste)(e,this.textarea,this.coreService,this.optionsService)}attachCustomKeyEventHandler(e){this._customKeyEventHandler=e}attachCustomWheelEventHandler(e){this._customWheelEventHandler=e}registerLinkProvider(e){return this._linkProviderService.registerLinkProvider(e)}registerCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");const t=this._characterJoinerService.register(e);return this.refresh(0,this.rows-1),t}deregisterCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");this._characterJoinerService.deregister(e)&&this.refresh(0,this.rows-1)}get markers(){return this.buffer.markers}registerMarker(e){return this.buffer.addMarker(this.buffer.ybase+this.buffer.y+e)}registerDecoration(e){return this._decorationService.registerDecoration(e)}hasSelection(){return!!this._selectionService&&this._selectionService.hasSelection}select(e,t,i){this._selectionService.setSelection(e,t,i)}getSelection(){return this._selectionService?this._selectionService.selectionText:""}getSelectionPosition(){if(this._selectionService&&this._selectionService.hasSelection)return{start:{x:this._selectionService.selectionStart[0],y:this._selectionService.selectionStart[1]},end:{x:this._selectionService.selectionEnd[0],y:this._selectionService.selectionEnd[1]}}}clearSelection(){this._selectionService?.clearSelection()}selectAll(){this._selectionService?.selectAll()}selectLines(e,t){this._selectionService?.selectLines(e,t)}_keyDown(e){if(this._keyDownHandled=!1,this._keyDownSeen=!0,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;const t=this.browser.isMac&&this.options.macOptionIsMeta&&e.altKey;if(!t&&!this._compositionHelper.keydown(e))return this.options.scrollOnUserInput&&this.buffer.ybase!==this.buffer.ydisp&&this.scrollToBottom(!0),!1;t||"Dead"!==e.key&&"AltGraph"!==e.key||(this._unprocessedDeadKey=!0);const i=(0,D.evaluateKeyboardEvent)(e,this.coreService.decPrivateModes.applicationCursorKeys,this.browser.isMac,this.options.macOptionIsMeta);if(this.updateCursorStyle(e),3===i.type||2===i.type){const t=this.rows-1;return this.scrollLines(2===i.type?-t:t),this.cancel(e,!0)}return 1===i.type&&this.selectAll(),!!this._isThirdLevelShift(this.browser,e)||(i.cancel&&this.cancel(e,!0),!i.key||!!(e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&1===e.key.length&&e.key.charCodeAt(0)>=65&&e.key.charCodeAt(0)<=90)||(this._unprocessedDeadKey?(this._unprocessedDeadKey=!1,!0):(i.key!==E.C0.ETX&&i.key!==E.C0.CR||(this.textarea.value=""),this._onKey.fire({key:i.key,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(i.key,!0),!this.optionsService.rawOptions.screenReaderMode||e.altKey||e.ctrlKey?this.cancel(e,!0):void(this._keyDownHandled=!0))))}_isThirdLevelShift(e,t){const i=e.isMac&&!this.options.macOptionIsMeta&&t.altKey&&!t.ctrlKey&&!t.metaKey||e.isWindows&&t.altKey&&t.ctrlKey&&!t.metaKey||e.isWindows&&t.getModifierState("AltGraph");return"keypress"===t.type?i:i&&(!t.keyCode||t.keyCode>47)}_keyUp(e){this._keyDownSeen=!1,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e)||(function(e){return 16===e.keyCode||17===e.keyCode||18===e.keyCode}(e)||this.focus(),this.updateCursorStyle(e),this._keyPressHandled=!1)}_keyPress(e){let t;if(this._keyPressHandled=!1,this._keyDownHandled)return!1;if(this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;if(this.cancel(e),e.charCode)t=e.charCode;else if(null===e.which||void 0===e.which)t=e.keyCode;else{if(0===e.which||0===e.charCode)return!1;t=e.which}return!(!t||(e.altKey||e.ctrlKey||e.metaKey)&&!this._isThirdLevelShift(this.browser,e)||(t=String.fromCharCode(t),this._onKey.fire({key:t,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(t,!0),this._keyPressHandled=!0,this._unprocessedDeadKey=!1,0))}_inputEvent(e){if(e.data&&"insertText"===e.inputType&&(!e.composed||!this._keyDownSeen)&&!this.optionsService.rawOptions.screenReaderMode){if(this._keyPressHandled)return!1;this._unprocessedDeadKey=!1;const t=e.data;return this.coreService.triggerDataEvent(t,!0),this.cancel(e),!0}return!1}resize(e,t){e!==this.cols||t!==this.rows?super.resize(e,t):this._charSizeService&&!this._charSizeService.hasValidSize&&this._charSizeService.measure()}_afterResize(e,t){this._charSizeService?.measure()}clear(){if(0!==this.buffer.ybase||0!==this.buffer.y){this.buffer.clearAllMarkers(),this.buffer.lines.set(0,this.buffer.lines.get(this.buffer.ybase+this.buffer.y)),this.buffer.lines.length=1,this.buffer.ydisp=0,this.buffer.ybase=0,this.buffer.y=0;for(let e=1;e=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Linkifier=void 0;const n=i(7150),o=i(6501),a=i(7098),l=i(802),h=i(7093);let c=class extends n.Disposable{get currentLink(){return this._currentLink}constructor(e,t,i,s,r){super(),this._element=e,this._mouseService=t,this._renderService=i,this._bufferService=s,this._linkProviderService=r,this._linkCacheDisposables=[],this._isMouseOut=!0,this._wasResized=!1,this._activeLine=-1,this._onShowLinkUnderline=this._register(new l.Emitter),this.onShowLinkUnderline=this._onShowLinkUnderline.event,this._onHideLinkUnderline=this._register(new l.Emitter),this.onHideLinkUnderline=this._onHideLinkUnderline.event,this._register((0,n.toDisposable)((()=>{(0,n.dispose)(this._linkCacheDisposables),this._linkCacheDisposables.length=0,this._lastMouseEvent=void 0,this._activeProviderReplies?.clear()}))),this._register(this._bufferService.onResize((()=>{this._clearCurrentLink(),this._wasResized=!0}))),this._register((0,h.addDisposableListener)(this._element,"mouseleave",(()=>{this._isMouseOut=!0,this._clearCurrentLink()}))),this._register((0,h.addDisposableListener)(this._element,"mousemove",this._handleMouseMove.bind(this))),this._register((0,h.addDisposableListener)(this._element,"mousedown",this._handleMouseDown.bind(this))),this._register((0,h.addDisposableListener)(this._element,"mouseup",this._handleMouseUp.bind(this)))}_handleMouseMove(e){this._lastMouseEvent=e;const t=this._positionFromMouseEvent(e,this._element,this._mouseService);if(!t)return;this._isMouseOut=!1;const i=e.composedPath();for(let e=0;e{e?.forEach((e=>{e.link.dispose&&e.link.dispose()}))})),this._activeProviderReplies=new Map,this._activeLine=e.y);let i=!1;for(const[s,r]of this._linkProviderService.linkProviders.entries())if(t){const t=this._activeProviderReplies?.get(s);t&&(i=this._checkLinkProviderResult(s,e,i))}else r.provideLinks(e.y,(t=>{if(this._isMouseOut)return;const r=t?.map((e=>({link:e})));this._activeProviderReplies?.set(s,r),i=this._checkLinkProviderResult(s,e,i),this._activeProviderReplies?.size===this._linkProviderService.linkProviders.length&&this._removeIntersectingLinks(e.y,this._activeProviderReplies)}))}_removeIntersectingLinks(e,t){const i=new Set;for(let s=0;se?this._bufferService.cols:s.link.range.end.x;for(let e=n;e<=o;e++){if(i.has(e)){r.splice(t--,1);break}i.add(e)}}}}_checkLinkProviderResult(e,t,i){if(!this._activeProviderReplies)return i;const s=this._activeProviderReplies.get(e);let r=!1;for(let t=0;tthis._linkAtPosition(e.link,t)));e&&(i=!0,this._handleNewLink(e))}if(this._activeProviderReplies.size===this._linkProviderService.linkProviders.length&&!i)for(let e=0;ethis._linkAtPosition(e.link,t)));if(s){i=!0,this._handleNewLink(s);break}}return i}_handleMouseDown(){this._mouseDownLink=this._currentLink}_handleMouseUp(e){if(!this._currentLink)return;const t=this._positionFromMouseEvent(e,this._element,this._mouseService);var i,s;t&&this._mouseDownLink&&(i=this._mouseDownLink.link,s=this._currentLink.link,i.text===s.text&&i.range.start.x===s.range.start.x&&i.range.start.y===s.range.start.y&&i.range.end.x===s.range.end.x&&i.range.end.y===s.range.end.y)&&this._linkAtPosition(this._currentLink.link,t)&&this._currentLink.link.activate(e,this._currentLink.link.text)}_clearCurrentLink(e,t){this._currentLink&&this._lastMouseEvent&&(!e||!t||this._currentLink.link.range.start.y>=e&&this._currentLink.link.range.end.y<=t)&&(this._linkLeave(this._element,this._currentLink.link,this._lastMouseEvent),this._currentLink=void 0,(0,n.dispose)(this._linkCacheDisposables),this._linkCacheDisposables.length=0)}_handleNewLink(e){if(!this._lastMouseEvent)return;const t=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);t&&this._linkAtPosition(e.link,t)&&(this._currentLink=e,this._currentLink.state={decorations:{underline:void 0===e.link.decorations||e.link.decorations.underline,pointerCursor:void 0===e.link.decorations||e.link.decorations.pointerCursor},isHovered:!0},this._linkHover(this._element,e.link,this._lastMouseEvent),e.link.decorations={},Object.defineProperties(e.link.decorations,{pointerCursor:{get:()=>this._currentLink?.state?.decorations.pointerCursor,set:e=>{this._currentLink?.state&&this._currentLink.state.decorations.pointerCursor!==e&&(this._currentLink.state.decorations.pointerCursor=e,this._currentLink.state.isHovered&&this._element.classList.toggle("xterm-cursor-pointer",e))}},underline:{get:()=>this._currentLink?.state?.decorations.underline,set:t=>{this._currentLink?.state&&this._currentLink?.state?.decorations.underline!==t&&(this._currentLink.state.decorations.underline=t,this._currentLink.state.isHovered&&this._fireUnderlineEvent(e.link,t))}}}),this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange((e=>{if(!this._currentLink)return;const t=0===e.start?0:e.start+1+this._bufferService.buffer.ydisp,i=this._bufferService.buffer.ydisp+1+e.end;if(this._currentLink.link.range.start.y>=t&&this._currentLink.link.range.end.y<=i&&(this._clearCurrentLink(t,i),this._lastMouseEvent)){const e=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);e&&this._askForLink(e,!1)}}))))}_linkHover(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!0,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!0),this._currentLink.state.decorations.pointerCursor&&e.classList.add("xterm-cursor-pointer")),t.hover&&t.hover(i,t.text)}_fireUnderlineEvent(e,t){const i=e.range,s=this._bufferService.buffer.ydisp,r=this._createLinkUnderlineEvent(i.start.x-1,i.start.y-s-1,i.end.x,i.end.y-s-1,void 0);(t?this._onShowLinkUnderline:this._onHideLinkUnderline).fire(r)}_linkLeave(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!1,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!1),this._currentLink.state.decorations.pointerCursor&&e.classList.remove("xterm-cursor-pointer")),t.leave&&t.leave(i,t.text)}_linkAtPosition(e,t){const i=e.range.start.y*this._bufferService.cols+e.range.start.x,s=e.range.end.y*this._bufferService.cols+e.range.end.x,r=t.y*this._bufferService.cols+t.x;return i<=r&&r<=s}_positionFromMouseEvent(e,t,i){const s=i.getCoords(e,t,this._bufferService.cols,this._bufferService.rows);if(s)return{x:s[0],y:s[1]+this._bufferService.buffer.ydisp}}_createLinkUnderlineEvent(e,t,i,s,r){return{x1:e,y1:t,x2:i,y2:s,cols:this._bufferService.cols,fg:r}}};t.Linkifier=c,t.Linkifier=c=s([r(1,a.IMouseService),r(2,a.IRenderService),r(3,o.IBufferService),r(4,a.ILinkProviderService)],c)},7721:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.tooMuchOutput=t.promptLabel=void 0;let i="Terminal input";const s={get:()=>i,set:e=>i=e};t.promptLabel=s;let r="Too much output to announce, navigate to rows manually to read";const n={get:()=>r,set:e=>r=e};t.tooMuchOutput=n},3285:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkProvider=void 0;const n=i(3055),o=i(6501);let a=class{constructor(e,t,i){this._bufferService=e,this._optionsService=t,this._oscLinkService=i}provideLinks(e,t){const i=this._bufferService.buffer.lines.get(e-1);if(!i)return void t(void 0);const s=[],r=this._optionsService.rawOptions.linkHandler,o=new n.CellData,a=i.getTrimmedLength();let h=-1,c=-1,d=!1;for(let t=0;tr?r.activate(e,t,n):l(0,t),hover:(e,t)=>r?.hover?.(e,t,n),leave:(e,t)=>r?.leave?.(e,t,n)})}d=!1,o.hasExtendedAttrs()&&o.extended.urlId?(c=t,h=o.extended.urlId):(c=-1,h=-1)}}t(s)}};function l(e,t){if(confirm(`Do you want to navigate to ${t}?\n\nWARNING: This link could potentially be dangerous`)){const e=window.open();if(e){try{e.opener=null}catch{}e.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}}t.OscLinkProvider=a,t.OscLinkProvider=a=s([r(0,o.IBufferService),r(1,o.IOptionsService),r(2,o.IOscLinkService)],a)},4852:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.RenderDebouncer=void 0,t.RenderDebouncer=class{constructor(e,t){this._renderCallback=e,this._coreBrowserService=t,this._refreshCallbacks=[]}dispose(){this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)}addRefreshCallback(e){return this._refreshCallbacks.push(e),this._animationFrame||(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh()))),this._animationFrame}refresh(e,t,i){this._rowCount=i,e=void 0!==e?e:0,t=void 0!==t?t:this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t,this._animationFrame||(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh())))}_innerRefresh(){if(this._animationFrame=void 0,void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return void this._runRefreshCallbacks();const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t),this._runRefreshCallbacks()}_runRefreshCallbacks(){for(const e of this._refreshCallbacks)e(0);this._refreshCallbacks=[]}}},4292:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.TimeBasedDebouncer=void 0,t.TimeBasedDebouncer=class{constructor(e,t=1e3){this._renderCallback=e,this._debounceThresholdMS=t,this._lastRefreshMs=0,this._additionalRefreshRequested=!1}dispose(){this._refreshTimeoutID&&clearTimeout(this._refreshTimeoutID)}refresh(e,t,i){this._rowCount=i,e=void 0!==e?e:0,t=void 0!==t?t:this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t;const s=performance.now();if(s-this._lastRefreshMs>=this._debounceThresholdMS)this._lastRefreshMs=s,this._innerRefresh();else if(!this._additionalRefreshRequested){const e=s-this._lastRefreshMs,t=this._debounceThresholdMS-e;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout((()=>{this._lastRefreshMs=performance.now(),this._innerRefresh(),this._additionalRefreshRequested=!1,this._refreshTimeoutID=void 0}),t)}}_innerRefresh(){if(void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return;const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t)}}},9302:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_ANSI_COLORS=void 0;const s=i(4103);t.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const e=[s.css.toColor("#2e3436"),s.css.toColor("#cc0000"),s.css.toColor("#4e9a06"),s.css.toColor("#c4a000"),s.css.toColor("#3465a4"),s.css.toColor("#75507b"),s.css.toColor("#06989a"),s.css.toColor("#d3d7cf"),s.css.toColor("#555753"),s.css.toColor("#ef2929"),s.css.toColor("#8ae234"),s.css.toColor("#fce94f"),s.css.toColor("#729fcf"),s.css.toColor("#ad7fa8"),s.css.toColor("#34e2e2"),s.css.toColor("#eeeeec")],t=[0,95,135,175,215,255];for(let i=0;i<216;i++){const r=t[i/36%6|0],n=t[i/6%6|0],o=t[i%6];e.push({css:s.channels.toCss(r,n,o),rgba:s.channels.toRgba(r,n,o)})}for(let t=0;t<24;t++){const i=8+10*t;e.push({css:s.channels.toCss(i,i,i),rgba:s.channels.toRgba(i,i,i)})}return e})())},4017:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Viewport=void 0;const n=i(7098),o=i(7150),a=i(6501),l=i(7093),h=i(8234),c=i(802),d=i(9881);let u=class extends o.Disposable{constructor(e,t,i,s,r,n,a,u){super(),this._bufferService=i,this._optionsService=a,this._renderService=u,this._onRequestScrollLines=this._register(new c.Emitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this._isSyncing=!1,this._isHandlingScroll=!1,this._suppressOnScrollHandler=!1;const _=this._register(new d.Scrollable({forceIntegerValues:!1,smoothScrollDuration:this._optionsService.rawOptions.smoothScrollDuration,scheduleAtNextAnimationFrame:e=>(0,l.scheduleAtNextAnimationFrame)(s.window,e)}));this._register(this._optionsService.onSpecificOptionChange("smoothScrollDuration",(()=>{_.setSmoothScrollDuration(this._optionsService.rawOptions.smoothScrollDuration)}))),this._scrollableElement=this._register(new h.SmoothScrollableElement(t,{vertical:1,horizontal:2,useShadows:!1,mouseWheelSmoothScroll:!0,...this._getChangeOptions()},_)),this._register(this._optionsService.onMultipleOptionChange(["scrollSensitivity","fastScrollSensitivity","overviewRuler"],(()=>this._scrollableElement.updateOptions(this._getChangeOptions())))),this._register(r.onProtocolChange((e=>{this._scrollableElement.updateOptions({handleMouseWheel:!(16&e)})}))),this._scrollableElement.setScrollDimensions({height:0,scrollHeight:0}),this._register(c.Event.runAndSubscribe(n.onChangeColors,(()=>{this._scrollableElement.getDomNode().style.backgroundColor=n.colors.background.css}))),e.appendChild(this._scrollableElement.getDomNode()),this._register((0,o.toDisposable)((()=>this._scrollableElement.getDomNode().remove()))),this._styleElement=s.mainDocument.createElement("style"),t.appendChild(this._styleElement),this._register((0,o.toDisposable)((()=>this._styleElement.remove()))),this._register(c.Event.runAndSubscribe(n.onChangeColors,(()=>{this._styleElement.textContent=[".xterm .xterm-scrollable-element > .scrollbar > .slider {",` background: ${n.colors.scrollbarSliderBackground.css};`,"}",".xterm .xterm-scrollable-element > .scrollbar > .slider:hover {",` background: ${n.colors.scrollbarSliderHoverBackground.css};`,"}",".xterm .xterm-scrollable-element > .scrollbar > .slider.active {",` background: ${n.colors.scrollbarSliderActiveBackground.css};`,"}"].join("\n")}))),this._register(this._bufferService.onResize((()=>this.queueSync()))),this._register(this._bufferService.buffers.onBufferActivate((()=>{this._latestYDisp=void 0,this.queueSync()}))),this._register(this._bufferService.onScroll((()=>this._sync()))),this._register(this._scrollableElement.onScroll((e=>this._handleScroll(e))))}scrollLines(e){const t=this._scrollableElement.getScrollPosition();this._scrollableElement.setScrollPosition({reuseAnimation:!0,scrollTop:t.scrollTop+e*this._renderService.dimensions.css.cell.height})}scrollToLine(e,t){t&&(this._latestYDisp=e),this._scrollableElement.setScrollPosition({reuseAnimation:!t,scrollTop:e*this._renderService.dimensions.css.cell.height})}_getChangeOptions(){return{mouseWheelScrollSensitivity:this._optionsService.rawOptions.scrollSensitivity,fastScrollSensitivity:this._optionsService.rawOptions.fastScrollSensitivity,verticalScrollbarSize:this._optionsService.rawOptions.overviewRuler?.width||14}}queueSync(e){void 0!==e&&(this._latestYDisp=e),void 0===this._queuedAnimationFrame&&(this._queuedAnimationFrame=this._renderService.addRefreshCallback((()=>{this._queuedAnimationFrame=void 0,this._sync(this._latestYDisp)})))}_sync(e=this._bufferService.buffer.ydisp){this._renderService&&!this._isSyncing&&(this._isSyncing=!0,this._suppressOnScrollHandler=!0,this._scrollableElement.setScrollDimensions({height:this._renderService.dimensions.css.canvas.height,scrollHeight:this._renderService.dimensions.css.cell.height*this._bufferService.buffer.lines.length}),this._suppressOnScrollHandler=!1,e!==this._latestYDisp&&this._scrollableElement.setScrollPosition({scrollTop:e*this._renderService.dimensions.css.cell.height}),this._isSyncing=!1)}_handleScroll(e){if(!this._renderService)return;if(this._isHandlingScroll||this._suppressOnScrollHandler)return;this._isHandlingScroll=!0;const t=Math.round(e.scrollTop/this._renderService.dimensions.css.cell.height),i=t-this._bufferService.buffer.ydisp;0!==i&&(this._latestYDisp=t,this._onRequestScrollLines.fire(i)),this._isHandlingScroll=!1}};t.Viewport=u,t.Viewport=u=s([r(2,a.IBufferService),r(3,n.ICoreBrowserService),r(4,a.ICoreMouseService),r(5,n.IThemeService),r(6,a.IOptionsService),r(7,n.IRenderService)],u)},4196:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferDecorationRenderer=void 0;const n=i(7098),o=i(7150),a=i(6501);let l=class extends o.Disposable{constructor(e,t,i,s,r){super(),this._screenElement=e,this._bufferService=t,this._coreBrowserService=i,this._decorationService=s,this._renderService=r,this._decorationElements=new Map,this._altBufferIsActive=!1,this._dimensionsChanged=!1,this._container=document.createElement("div"),this._container.classList.add("xterm-decoration-container"),this._screenElement.appendChild(this._container),this._register(this._renderService.onRenderedViewportChange((()=>this._doRefreshDecorations()))),this._register(this._renderService.onDimensionsChange((()=>{this._dimensionsChanged=!0,this._queueRefresh()}))),this._register(this._coreBrowserService.onDprChange((()=>this._queueRefresh()))),this._register(this._bufferService.buffers.onBufferActivate((()=>{this._altBufferIsActive=this._bufferService.buffer===this._bufferService.buffers.alt}))),this._register(this._decorationService.onDecorationRegistered((()=>this._queueRefresh()))),this._register(this._decorationService.onDecorationRemoved((e=>this._removeDecoration(e)))),this._register((0,o.toDisposable)((()=>{this._container.remove(),this._decorationElements.clear()})))}_queueRefresh(){void 0===this._animationFrame&&(this._animationFrame=this._renderService.addRefreshCallback((()=>{this._doRefreshDecorations(),this._animationFrame=void 0})))}_doRefreshDecorations(){for(const e of this._decorationService.decorations)this._renderDecoration(e);this._dimensionsChanged=!1}_renderDecoration(e){this._refreshStyle(e),this._dimensionsChanged&&this._refreshXPosition(e)}_createElement(e){const t=this._coreBrowserService.mainDocument.createElement("div");t.classList.add("xterm-decoration"),t.classList.toggle("xterm-decoration-top-layer","top"===e?.options?.layer),t.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,t.style.height=(e.options.height||1)*this._renderService.dimensions.css.cell.height+"px",t.style.top=(e.marker.line-this._bufferService.buffers.active.ydisp)*this._renderService.dimensions.css.cell.height+"px",t.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`;const i=e.options.x??0;return i&&i>this._bufferService.cols&&(t.style.display="none"),this._refreshXPosition(e,t),t}_refreshStyle(e){const t=e.marker.line-this._bufferService.buffers.active.ydisp;if(t<0||t>=this._bufferService.rows)e.element&&(e.element.style.display="none",e.onRenderEmitter.fire(e.element));else{let i=this._decorationElements.get(e);i||(i=this._createElement(e),e.element=i,this._decorationElements.set(e,i),this._container.appendChild(i),e.onDispose((()=>{this._decorationElements.delete(e),i.remove()}))),i.style.display=this._altBufferIsActive?"none":"block",this._altBufferIsActive||(i.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,i.style.height=(e.options.height||1)*this._renderService.dimensions.css.cell.height+"px",i.style.top=t*this._renderService.dimensions.css.cell.height+"px",i.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`),e.onRenderEmitter.fire(i)}}_refreshXPosition(e,t=e.element){if(!t)return;const i=e.options.x??0;"right"===(e.options.anchor||"left")?t.style.right=i?i*this._renderService.dimensions.css.cell.width+"px":"":t.style.left=i?i*this._renderService.dimensions.css.cell.width+"px":""}_removeDecoration(e){this._decorationElements.get(e)?.remove(),this._decorationElements.delete(e),e.dispose()}};t.BufferDecorationRenderer=l,t.BufferDecorationRenderer=l=s([r(1,a.IBufferService),r(2,n.ICoreBrowserService),r(3,a.IDecorationService),r(4,n.IRenderService)],l)},957:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ColorZoneStore=void 0,t.ColorZoneStore=class{constructor(){this._zones=[],this._zonePool=[],this._zonePoolIndex=0,this._linePadding={full:0,left:0,center:0,right:0}}get zones(){return this._zonePool.length=Math.min(this._zonePool.length,this._zones.length),this._zones}clear(){this._zones.length=0,this._zonePoolIndex=0}addDecoration(e){if(e.options.overviewRulerOptions){for(const t of this._zones)if(t.color===e.options.overviewRulerOptions.color&&t.position===e.options.overviewRulerOptions.position){if(this._lineIntersectsZone(t,e.marker.line))return;if(this._lineAdjacentToZone(t,e.marker.line,e.options.overviewRulerOptions.position))return void this._addLineToZone(t,e.marker.line)}if(this._zonePoolIndex=e.startBufferLine&&t<=e.endBufferLine}_lineAdjacentToZone(e,t,i){return t>=e.startBufferLine-this._linePadding[i||"full"]&&t<=e.endBufferLine+this._linePadding[i||"full"]}_addLineToZone(e,t){e.startBufferLine=Math.min(e.startBufferLine,t),e.endBufferLine=Math.max(e.endBufferLine,t)}}},9925:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OverviewRulerRenderer=void 0;const n=i(957),o=i(7098),a=i(7150),l=i(6501),h={full:0,left:0,center:0,right:0},c={full:0,left:0,center:0,right:0},d={full:0,left:0,center:0,right:0};let u=class extends a.Disposable{get _width(){return this._optionsService.options.overviewRuler?.width||0}constructor(e,t,i,s,r,o,l,h){super(),this._viewportElement=e,this._screenElement=t,this._bufferService=i,this._decorationService=s,this._renderService=r,this._optionsService=o,this._themeService=l,this._coreBrowserService=h,this._colorZoneStore=new n.ColorZoneStore,this._shouldUpdateDimensions=!0,this._shouldUpdateAnchor=!0,this._lastKnownBufferLength=0,this._canvas=this._coreBrowserService.mainDocument.createElement("canvas"),this._canvas.classList.add("xterm-decoration-overview-ruler"),this._refreshCanvasDimensions(),this._viewportElement.parentElement?.insertBefore(this._canvas,this._viewportElement),this._register((0,a.toDisposable)((()=>this._canvas?.remove())));const c=this._canvas.getContext("2d");if(!c)throw new Error("Ctx cannot be null");this._ctx=c,this._register(this._decorationService.onDecorationRegistered((()=>this._queueRefresh(void 0,!0)))),this._register(this._decorationService.onDecorationRemoved((()=>this._queueRefresh(void 0,!0)))),this._register(this._renderService.onRenderedViewportChange((()=>this._queueRefresh()))),this._register(this._bufferService.buffers.onBufferActivate((()=>{this._canvas.style.display=this._bufferService.buffer===this._bufferService.buffers.alt?"none":"block"}))),this._register(this._bufferService.onScroll((()=>{this._lastKnownBufferLength!==this._bufferService.buffers.normal.lines.length&&(this._refreshDrawHeightConstants(),this._refreshColorZonePadding())}))),this._register(this._renderService.onRender((()=>{this._containerHeight&&this._containerHeight===this._screenElement.clientHeight||(this._queueRefresh(!0),this._containerHeight=this._screenElement.clientHeight)}))),this._register(this._coreBrowserService.onDprChange((()=>this._queueRefresh(!0)))),this._register(this._optionsService.onSpecificOptionChange("overviewRuler",(()=>this._queueRefresh(!0)))),this._register(this._themeService.onChangeColors((()=>this._queueRefresh()))),this._queueRefresh(!0)}_refreshDrawConstants(){const e=Math.floor((this._canvas.width-1)/3),t=Math.ceil((this._canvas.width-1)/3);c.full=this._canvas.width,c.left=e,c.center=t,c.right=e,this._refreshDrawHeightConstants(),d.full=1,d.left=1,d.center=1+c.left,d.right=1+c.left+c.center}_refreshDrawHeightConstants(){h.full=Math.round(2*this._coreBrowserService.dpr);const e=this._canvas.height/this._bufferService.buffer.lines.length,t=Math.round(Math.max(Math.min(e,12),6)*this._coreBrowserService.dpr);h.left=t,h.center=t,h.right=t}_refreshColorZonePadding(){this._colorZoneStore.setPadding({full:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*h.full),left:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*h.left),center:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*h.center),right:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*h.right)}),this._lastKnownBufferLength=this._bufferService.buffers.normal.lines.length}_refreshCanvasDimensions(){this._canvas.style.width=`${this._width}px`,this._canvas.width=Math.round(this._width*this._coreBrowserService.dpr),this._canvas.style.height=`${this._screenElement.clientHeight}px`,this._canvas.height=Math.round(this._screenElement.clientHeight*this._coreBrowserService.dpr),this._refreshDrawConstants(),this._refreshColorZonePadding()}_refreshDecorations(){this._shouldUpdateDimensions&&this._refreshCanvasDimensions(),this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this._colorZoneStore.clear();for(const e of this._decorationService.decorations)this._colorZoneStore.addDecoration(e);this._ctx.lineWidth=1,this._renderRulerOutline();const e=this._colorZoneStore.zones;for(const t of e)"full"!==t.position&&this._renderColorZone(t);for(const t of e)"full"===t.position&&this._renderColorZone(t);this._shouldUpdateDimensions=!1,this._shouldUpdateAnchor=!1}_renderRulerOutline(){this._ctx.fillStyle=this._themeService.colors.overviewRulerBorder.css,this._ctx.fillRect(0,0,1,this._canvas.height),this._optionsService.rawOptions.overviewRuler.showTopBorder&&this._ctx.fillRect(1,0,this._canvas.width-1,1),this._optionsService.rawOptions.overviewRuler.showBottomBorder&&this._ctx.fillRect(1,this._canvas.height-1,this._canvas.width-1,this._canvas.height)}_renderColorZone(e){this._ctx.fillStyle=e.color,this._ctx.fillRect(d[e.position||"full"],Math.round((this._canvas.height-1)*(e.startBufferLine/this._bufferService.buffers.active.lines.length)-h[e.position||"full"]/2),c[e.position||"full"],Math.round((this._canvas.height-1)*((e.endBufferLine-e.startBufferLine)/this._bufferService.buffers.active.lines.length)+h[e.position||"full"]))}_queueRefresh(e,t){this._shouldUpdateDimensions=e||this._shouldUpdateDimensions,this._shouldUpdateAnchor=t||this._shouldUpdateAnchor,void 0===this._animationFrame&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>{this._refreshDecorations(),this._animationFrame=void 0})))}};t.OverviewRulerRenderer=u,t.OverviewRulerRenderer=u=s([r(2,l.IBufferService),r(3,l.IDecorationService),r(4,o.IRenderService),r(5,l.IOptionsService),r(6,o.IThemeService),r(7,o.ICoreBrowserService)],u)},3618:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CompositionHelper=void 0;const n=i(7098),o=i(6501),a=i(3534);let l=class{get isComposing(){return this._isComposing}constructor(e,t,i,s,r,n){this._textarea=e,this._compositionView=t,this._bufferService=i,this._optionsService=s,this._coreService=r,this._renderService=n,this._isComposing=!1,this._isSendingComposition=!1,this._compositionPosition={start:0,end:0},this._dataAlreadySent=""}compositionstart(){this._isComposing=!0,this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent="",this._dataAlreadySent="",this._compositionView.classList.add("active")}compositionupdate(e){this._compositionView.textContent=e.data,this.updateCompositionElements(),setTimeout((()=>{this._compositionPosition.end=this._textarea.value.length}),0)}compositionend(){this._finalizeComposition(!0)}keydown(e){if(this._isComposing||this._isSendingComposition){if(20===e.keyCode||229===e.keyCode)return!1;if(16===e.keyCode||17===e.keyCode||18===e.keyCode)return!1;this._finalizeComposition(!1)}return 229!==e.keyCode||(this._handleAnyTextareaChanges(),!1)}_finalizeComposition(e){if(this._compositionView.classList.remove("active"),this._isComposing=!1,e){const e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0,setTimeout((()=>{if(this._isSendingComposition){let t;this._isSendingComposition=!1,e.start+=this._dataAlreadySent.length,t=this._isComposing?this._textarea.value.substring(e.start,this._compositionPosition.start):this._textarea.value.substring(e.start),t.length>0&&this._coreService.triggerDataEvent(t,!0)}}),0)}else{this._isSendingComposition=!1;const e=this._textarea.value.substring(this._compositionPosition.start,this._compositionPosition.end);this._coreService.triggerDataEvent(e,!0)}}_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.lengththis.updateCompositionElements(!0)),0)}}};t.CompositionHelper=l,t.CompositionHelper=l=s([r(2,o.IBufferService),r(3,o.IOptionsService),r(4,o.ICoreService),r(5,n.IRenderService)],l)},5251:(e,t)=>{function i(e,t,i){const s=i.getBoundingClientRect(),r=e.getComputedStyle(i),n=parseInt(r.getPropertyValue("padding-left")),o=parseInt(r.getPropertyValue("padding-top"));return[t.clientX-s.left-n,t.clientY-s.top-o]}Object.defineProperty(t,"__esModule",{value:!0}),t.getCoordsRelativeToElement=i,t.getCoords=function(e,t,s,r,n,o,a,l,h){if(!o)return;const c=i(e,t,s);return c?(c[0]=Math.ceil((c[0]+(h?a/2:0))/a),c[1]=Math.ceil(c[1]/l),c[0]=Math.min(Math.max(c[0],1),r+(h?1:0)),c[1]=Math.min(Math.max(c[1],1),n),c):void 0}},9686:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.moveToCellSequence=function(e,t,i,s){const o=i.buffer.x,c=i.buffer.y;if(!i.buffer.hasScrollback)return function(e,t,i,s,o,c){return 0===r(t,s,o,c).length?"":h(a(e,t,e,t-n(t,o),!1,o).length,l("D",c))}(o,c,0,t,i,s)+r(c,t,i,s)+function(e,t,i,s,o,c){let d;d=r(t,s,o,c).length>0?s-n(s,o):t;const u=s,_=function(e,t,i,s,o,a){let l;return l=r(i,s,o,a).length>0?s-n(s,o):t,e=i&&le?"D":"C",h(Math.abs(o-e),l(d,s));d=c>t?"D":"C";const u=Math.abs(c-t);return h(function(e,t){return t.cols-e}(c>t?e:o,i)+(u-1)*i.cols+1+((c>t?o:e)-1),l(d,s))};const s=i(3534);function r(e,t,i,s){const r=e-n(e,i),a=t-n(t,i),c=Math.abs(r-a)-function(e,t,i){let s=0;const r=e-n(e,i),a=t-n(t,i);for(let n=0;n=0&&et?"A":"B"}function a(e,t,i,s,r,n){let o=e,a=t,l="";for(;(o!==i||a!==s)&&a>=0&&an.cols-1?(l+=n.buffer.translateBufferLineToString(a,!1,e,o),o=0,e=0,a++):!r&&o<0&&(l+=n.buffer.translateBufferLineToString(a,!1,0,e+1),o=n.cols-1,e=o,a--);return l+n.buffer.translateBufferLineToString(a,!1,e,o)}function l(e,t){const i=t?"O":"[";return s.C0.ESC+i+e}function h(e,t){e=Math.floor(e);let i="";for(let s=0;s=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRenderer=void 0;const n=i(1433),o=i(2744),a=i(9176),l=i(6181),h=i(2274),c=i(7098),d=i(4103),u=i(7150),_=i(6501),f=i(802),p="xterm-dom-renderer-owner-",g="xterm-rows",m="xterm-fg-",v="xterm-bg-",S="xterm-focus",b="xterm-selection";let C=1,y=class extends u.Disposable{constructor(e,t,i,s,r,a,c,d,_,m,v,S,y,w){super(),this._terminal=e,this._document=t,this._element=i,this._screenElement=s,this._viewportElement=r,this._helperContainer=a,this._linkifier2=c,this._charSizeService=_,this._optionsService=m,this._bufferService=v,this._coreService=S,this._coreBrowserService=y,this._themeService=w,this._terminalClass=C++,this._rowElements=[],this._selectionRenderModel=(0,h.createSelectionRenderModel)(),this.onRequestRedraw=this._register(new f.Emitter).event,this._rowContainer=this._document.createElement("div"),this._rowContainer.classList.add(g),this._rowContainer.style.lineHeight="normal",this._rowContainer.setAttribute("aria-hidden","true"),this._refreshRowElements(this._bufferService.cols,this._bufferService.rows),this._selectionContainer=this._document.createElement("div"),this._selectionContainer.classList.add(b),this._selectionContainer.setAttribute("aria-hidden","true"),this.dimensions=(0,l.createRenderDimensions)(),this._updateDimensions(),this._register(this._optionsService.onOptionChange((()=>this._handleOptionsChanged()))),this._register(this._themeService.onChangeColors((e=>this._injectCss(e)))),this._injectCss(this._themeService.colors),this._rowFactory=d.createInstance(n.DomRendererRowFactory,document),this._element.classList.add(p+this._terminalClass),this._screenElement.appendChild(this._rowContainer),this._screenElement.appendChild(this._selectionContainer),this._register(this._linkifier2.onShowLinkUnderline((e=>this._handleLinkHover(e)))),this._register(this._linkifier2.onHideLinkUnderline((e=>this._handleLinkLeave(e)))),this._register((0,u.toDisposable)((()=>{this._element.classList.remove(p+this._terminalClass),this._rowContainer.remove(),this._selectionContainer.remove(),this._widthCache.dispose(),this._themeStyleElement.remove(),this._dimensionsStyleElement.remove()}))),this._widthCache=new o.WidthCache(this._document,this._helperContainer),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}_updateDimensions(){const e=this._coreBrowserService.dpr;this.dimensions.device.char.width=this._charSizeService.width*e,this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*e),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.left=0,this.dimensions.device.char.top=0,this.dimensions.device.canvas.width=this.dimensions.device.cell.width*this._bufferService.cols,this.dimensions.device.canvas.height=this.dimensions.device.cell.height*this._bufferService.rows,this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/e),this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/e),this.dimensions.css.cell.width=this.dimensions.css.canvas.width/this._bufferService.cols,this.dimensions.css.cell.height=this.dimensions.css.canvas.height/this._bufferService.rows;for(const e of this._rowElements)e.style.width=`${this.dimensions.css.canvas.width}px`,e.style.height=`${this.dimensions.css.cell.height}px`,e.style.lineHeight=`${this.dimensions.css.cell.height}px`,e.style.overflow="hidden";this._dimensionsStyleElement||(this._dimensionsStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._dimensionsStyleElement));const t=`${this._terminalSelector} .${g} span { display: inline-block; height: 100%; vertical-align: top;}`;this._dimensionsStyleElement.textContent=t,this._selectionContainer.style.height=this._viewportElement.style.height,this._screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._screenElement.style.height=`${this.dimensions.css.canvas.height}px`}_injectCss(e){this._themeStyleElement||(this._themeStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._themeStyleElement));let t=`${this._terminalSelector} .${g} { pointer-events: none; color: ${e.foreground.css}; font-family: ${this._optionsService.rawOptions.fontFamily}; font-size: ${this._optionsService.rawOptions.fontSize}px; font-kerning: none; white-space: pre}`;t+=`${this._terminalSelector} .${g} .xterm-dim { color: ${d.color.multiplyOpacity(e.foreground,.5).css};}`,t+=`${this._terminalSelector} span:not(.xterm-bold) { font-weight: ${this._optionsService.rawOptions.fontWeight};}${this._terminalSelector} span.xterm-bold { font-weight: ${this._optionsService.rawOptions.fontWeightBold};}${this._terminalSelector} span.xterm-italic { font-style: italic;}`;const i=`blink_underline_${this._terminalClass}`,s=`blink_bar_${this._terminalClass}`,r=`blink_block_${this._terminalClass}`;t+=`@keyframes ${i} { 50% { border-bottom-style: hidden; }}`,t+=`@keyframes ${s} { 50% { box-shadow: none; }}`,t+=`@keyframes ${r} { 0% { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css}; } 50% { background-color: inherit; color: ${e.cursor.css}; }}`,t+=`${this._terminalSelector} .${g}.${S} .xterm-cursor.xterm-cursor-blink.xterm-cursor-underline { animation: ${i} 1s step-end infinite;}${this._terminalSelector} .${g}.${S} .xterm-cursor.xterm-cursor-blink.xterm-cursor-bar { animation: ${s} 1s step-end infinite;}${this._terminalSelector} .${g}.${S} .xterm-cursor.xterm-cursor-blink.xterm-cursor-block { animation: ${r} 1s step-end infinite;}${this._terminalSelector} .${g} .xterm-cursor.xterm-cursor-block { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css};}${this._terminalSelector} .${g} .xterm-cursor.xterm-cursor-block:not(.xterm-cursor-blink) { background-color: ${e.cursor.css} !important; color: ${e.cursorAccent.css} !important;}${this._terminalSelector} .${g} .xterm-cursor.xterm-cursor-outline { outline: 1px solid ${e.cursor.css}; outline-offset: -1px;}${this._terminalSelector} .${g} .xterm-cursor.xterm-cursor-bar { box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${e.cursor.css} inset;}${this._terminalSelector} .${g} .xterm-cursor.xterm-cursor-underline { border-bottom: 1px ${e.cursor.css}; border-bottom-style: solid; height: calc(100% - 1px);}`,t+=`${this._terminalSelector} .${b} { position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;}${this._terminalSelector}.focus .${b} div { position: absolute; background-color: ${e.selectionBackgroundOpaque.css};}${this._terminalSelector} .${b} div { position: absolute; background-color: ${e.selectionInactiveBackgroundOpaque.css};}`;for(const[i,s]of e.ansi.entries())t+=`${this._terminalSelector} .${m}${i} { color: ${s.css}; }${this._terminalSelector} .${m}${i}.xterm-dim { color: ${d.color.multiplyOpacity(s,.5).css}; }${this._terminalSelector} .${v}${i} { background-color: ${s.css}; }`;t+=`${this._terminalSelector} .${m}${a.INVERTED_DEFAULT_COLOR} { color: ${d.color.opaque(e.background).css}; }${this._terminalSelector} .${m}${a.INVERTED_DEFAULT_COLOR}.xterm-dim { color: ${d.color.multiplyOpacity(d.color.opaque(e.background),.5).css}; }${this._terminalSelector} .${v}${a.INVERTED_DEFAULT_COLOR} { background-color: ${e.foreground.css}; }`,this._themeStyleElement.textContent=t}_setDefaultSpacing(){const e=this.dimensions.css.cell.width-this._widthCache.get("W",!1,!1);this._rowContainer.style.letterSpacing=`${e}px`,this._rowFactory.defaultSpacing=e}handleDevicePixelRatioChange(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}_refreshRowElements(e,t){for(let e=this._rowElements.length;e<=t;e++){const e=this._document.createElement("div");this._rowContainer.appendChild(e),this._rowElements.push(e)}for(;this._rowElements.length>t;)this._rowContainer.removeChild(this._rowElements.pop())}handleResize(e,t){this._refreshRowElements(e,t),this._updateDimensions(),this.handleSelectionChanged(this._selectionRenderModel.selectionStart,this._selectionRenderModel.selectionEnd,this._selectionRenderModel.columnSelectMode)}handleCharSizeChanged(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}handleBlur(){this._rowContainer.classList.remove(S),this.renderRows(0,this._bufferService.rows-1)}handleFocus(){this._rowContainer.classList.add(S),this.renderRows(this._bufferService.buffer.y,this._bufferService.buffer.y)}handleSelectionChanged(e,t,i){if(this._selectionContainer.replaceChildren(),this._rowFactory.handleSelectionChanged(e,t,i),this.renderRows(0,this._bufferService.rows-1),!e||!t)return;if(this._selectionRenderModel.update(this._terminal,e,t,i),!this._selectionRenderModel.hasSelection)return;const s=this._selectionRenderModel.viewportStartRow,r=this._selectionRenderModel.viewportEndRow,n=this._selectionRenderModel.viewportCappedStartRow,o=this._selectionRenderModel.viewportCappedEndRow,a=this._document.createDocumentFragment();if(i){const i=e[0]>t[0];a.appendChild(this._createSelectionElement(n,i?t[0]:e[0],i?e[0]:t[0],o-n+1))}else{const i=s===n?e[0]:0,l=n===r?t[0]:this._bufferService.cols;a.appendChild(this._createSelectionElement(n,i,l));const h=o-n-1;if(a.appendChild(this._createSelectionElement(n+1,0,this._bufferService.cols,h)),n!==o){const e=r===o?t[0]:this._bufferService.cols;a.appendChild(this._createSelectionElement(o,0,e))}}this._selectionContainer.appendChild(a)}_createSelectionElement(e,t,i,s=1){const r=this._document.createElement("div"),n=t*this.dimensions.css.cell.width;let o=this.dimensions.css.cell.width*(i-t);return n+o>this.dimensions.css.canvas.width&&(o=this.dimensions.css.canvas.width-n),r.style.height=s*this.dimensions.css.cell.height+"px",r.style.top=e*this.dimensions.css.cell.height+"px",r.style.left=`${n}px`,r.style.width=`${o}px`,r}handleCursorMove(){}_handleOptionsChanged(){this._updateDimensions(),this._injectCss(this._themeService.colors),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}clear(){for(const e of this._rowElements)e.replaceChildren()}renderRows(e,t){const i=this._bufferService.buffer,s=i.ybase+i.y,r=Math.min(i.x,this._bufferService.cols-1),n=this._coreService.decPrivateModes.cursorBlink??this._optionsService.rawOptions.cursorBlink,o=this._coreService.decPrivateModes.cursorStyle??this._optionsService.rawOptions.cursorStyle,a=this._optionsService.rawOptions.cursorInactiveStyle;for(let l=e;l<=t;l++){const e=l+i.ydisp,t=this._rowElements[l],h=i.lines.get(e);if(!t||!h)break;t.replaceChildren(...this._rowFactory.createRow(h,e,e===s,o,a,r,n,this.dimensions.css.cell.width,this._widthCache,-1,-1))}}get _terminalSelector(){return`.${p}${this._terminalClass}`}_handleLinkHover(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!0)}_handleLinkLeave(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!1)}_setCellUnderline(e,t,i,s,r,n){i<0&&(e=0),s<0&&(t=0);const o=this._bufferService.rows-1;i=Math.max(Math.min(i,o),0),s=Math.max(Math.min(s,o),0),r=Math.min(r,this._bufferService.cols);const a=this._bufferService.buffer,l=a.ybase+a.y,h=Math.min(a.x,r-1),c=this._optionsService.rawOptions.cursorBlink,d=this._optionsService.rawOptions.cursorStyle,u=this._optionsService.rawOptions.cursorInactiveStyle;for(let o=i;o<=s;++o){const _=o+a.ydisp,f=this._rowElements[o],p=a.lines.get(_);if(!f||!p)break;f.replaceChildren(...this._rowFactory.createRow(p,_,_===l,d,u,h,c,this.dimensions.css.cell.width,this._widthCache,n?o===i?e:0:-1,n?(o===s?t:r)-1:-1))}}};t.DomRenderer=y,t.DomRenderer=y=s([r(7,_.IInstantiationService),r(8,c.ICharSizeService),r(9,_.IOptionsService),r(10,_.IBufferService),r(11,_.ICoreService),r(12,c.ICoreBrowserService),r(13,c.IThemeService)],y)},1433:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRendererRowFactory=void 0;const n=i(9176),o=i(8938),a=i(3055),l=i(6501),h=i(4103),c=i(7098),d=i(945),u=i(6181),_=i(5451);let f=class{constructor(e,t,i,s,r,n,o){this._document=e,this._characterJoinerService=t,this._optionsService=i,this._coreBrowserService=s,this._coreService=r,this._decorationService=n,this._themeService=o,this._workCell=new a.CellData,this._columnSelectMode=!1,this.defaultSpacing=0}handleSelectionChanged(e,t,i){this._selectionStart=e,this._selectionEnd=t,this._columnSelectMode=i}createRow(e,t,i,s,r,a,l,c,u,f,g){const m=[],v=this._characterJoinerService.getJoinedCharacters(t),S=this._themeService.colors;let b,C=e.getNoBgTrimmedLength();i&&C=O,U=x,F=this._workCell;if(v.length>0&&x===v[0][0]&&N){const s=v.shift(),r=this._isCellInSelection(s[0],t);for(E=s[0]+1;E=s[1],N?(B=!0,F=new d.JoinedCellData(this._workCell,e.translateToString(!0,s[0],s[1]),s[1]-s[0]),U=s[1]-1,C=F.getWidth()):O=s[1]}const W=this._isCellInSelection(x,t),H=i&&x===a,K=P&&x>=f&&x<=g;let z=!1;this._decorationService.forEachDecorationAtCell(x,t,void 0,(e=>{z=!0}));let j=F.getChars()||o.WHITESPACE_CELL_CHAR;if(" "===j&&(F.isUnderline()||F.isOverline())&&(j=" "),M=C*c-u.get(j,F.isBold(),F.isItalic()),b){if(y&&(W&&k||!W&&!k&&F.bg===D)&&(W&&k&&S.selectionForeground||F.fg===L)&&F.extended.ext===R&&K===A&&M===T&&!H&&!B&&!z&&N){F.isInvisible()?w+=o.WHITESPACE_CELL_CHAR:w+=j,y++;continue}y&&(b.textContent=w),b=this._document.createElement("span"),y=0,w=""}else b=this._document.createElement("span");if(D=F.bg,L=F.fg,R=F.extended.ext,A=K,T=M,k=W,B&&a>=x&&a<=U&&(a=x),!this._coreService.isCursorHidden&&H&&this._coreService.isCursorInitialized)if(I.push("xterm-cursor"),this._coreBrowserService.isFocused)l&&I.push("xterm-cursor-blink"),I.push("bar"===s?"xterm-cursor-bar":"underline"===s?"xterm-cursor-underline":"xterm-cursor-block");else if(r)switch(r){case"outline":I.push("xterm-cursor-outline");break;case"block":I.push("xterm-cursor-block");break;case"bar":I.push("xterm-cursor-bar");break;case"underline":I.push("xterm-cursor-underline")}if(F.isBold()&&I.push("xterm-bold"),F.isItalic()&&I.push("xterm-italic"),F.isDim()&&I.push("xterm-dim"),w=F.isInvisible()?o.WHITESPACE_CELL_CHAR:F.getChars()||o.WHITESPACE_CELL_CHAR,F.isUnderline()&&(I.push(`xterm-underline-${F.extended.underlineStyle}`)," "===w&&(w=" "),!F.isUnderlineColorDefault()))if(F.isUnderlineColorRGB())b.style.textDecorationColor=`rgb(${_.AttributeData.toColorRGB(F.getUnderlineColor()).join(",")})`;else{let e=F.getUnderlineColor();this._optionsService.rawOptions.drawBoldTextInBrightColors&&F.isBold()&&e<8&&(e+=8),b.style.textDecorationColor=S.ansi[e].css}F.isOverline()&&(I.push("xterm-overline")," "===w&&(w=" ")),F.isStrikethrough()&&I.push("xterm-strikethrough"),K&&(b.style.textDecoration="underline");let $=F.getFgColor(),V=F.getFgColorMode(),G=F.getBgColor(),q=F.getBgColorMode();const X=!!F.isInverse();if(X){const e=$;$=G,G=e;const t=V;V=q,q=t}let Y,Z,J,Q=!1;switch(this._decorationService.forEachDecorationAtCell(x,t,void 0,(e=>{"top"!==e.options.layer&&Q||(e.backgroundColorRGB&&(q=50331648,G=e.backgroundColorRGB.rgba>>8&16777215,Y=e.backgroundColorRGB),e.foregroundColorRGB&&(V=50331648,$=e.foregroundColorRGB.rgba>>8&16777215,Z=e.foregroundColorRGB),Q="top"===e.options.layer)})),!Q&&W&&(Y=this._coreBrowserService.isFocused?S.selectionBackgroundOpaque:S.selectionInactiveBackgroundOpaque,G=Y.rgba>>8&16777215,q=50331648,Q=!0,S.selectionForeground&&(V=50331648,$=S.selectionForeground.rgba>>8&16777215,Z=S.selectionForeground)),Q&&I.push("xterm-decoration-top"),q){case 16777216:case 33554432:J=S.ansi[G],I.push(`xterm-bg-${G}`);break;case 50331648:J=h.channels.toColor(G>>16,G>>8&255,255&G),this._addStyle(b,`background-color:#${p((G>>>0).toString(16),"0",6)}`);break;default:X?(J=S.foreground,I.push(`xterm-bg-${n.INVERTED_DEFAULT_COLOR}`)):J=S.background}switch(Y||F.isDim()&&(Y=h.color.multiplyOpacity(J,.5)),V){case 16777216:case 33554432:F.isBold()&&$<8&&this._optionsService.rawOptions.drawBoldTextInBrightColors&&($+=8),this._applyMinimumContrast(b,J,S.ansi[$],F,Y,void 0)||I.push(`xterm-fg-${$}`);break;case 50331648:const e=h.channels.toColor($>>16&255,$>>8&255,255&$);this._applyMinimumContrast(b,J,e,F,Y,Z)||this._addStyle(b,`color:#${p($.toString(16),"0",6)}`);break;default:this._applyMinimumContrast(b,J,S.foreground,F,Y,Z)||X&&I.push(`xterm-fg-${n.INVERTED_DEFAULT_COLOR}`)}I.length&&(b.className=I.join(" "),I.length=0),H||B||z||!N?b.textContent=w:y++,M!==this.defaultSpacing&&(b.style.letterSpacing=`${M}px`),m.push(b),x=U}return b&&y&&(b.textContent=w),m}_applyMinimumContrast(e,t,i,s,r,n){if(1===this._optionsService.rawOptions.minimumContrastRatio||(0,u.treatGlyphAsBackgroundColor)(s.getCode()))return!1;const o=this._getContrastCache(s);let a;if(r||n||(a=o.getColor(t.rgba,i.rgba)),void 0===a){const e=this._optionsService.rawOptions.minimumContrastRatio/(s.isDim()?2:1);a=h.color.ensureContrastRatio(r||t,n||i,e),o.setColor((r||t).rgba,(n||i).rgba,a??null)}return!!a&&(this._addStyle(e,`color:${a.css}`),!0)}_getContrastCache(e){return e.isDim()?this._themeService.colors.halfContrastCache:this._themeService.colors.contrastCache}_addStyle(e,t){e.setAttribute("style",`${e.getAttribute("style")||""}${t};`)}_isCellInSelection(e,t){const i=this._selectionStart,s=this._selectionEnd;return!(!i||!s)&&(this._columnSelectMode?i[0]<=s[0]?e>=i[0]&&t>=i[1]&&e=i[1]&&e>=s[0]&&t<=s[1]:t>i[1]&&t=i[0]&&e=i[0])}};function p(e,t,i){for(;e.length{Object.defineProperty(t,"__esModule",{value:!0}),t.WidthCache=void 0,t.WidthCache=class{constructor(e,t){this._flat=new Float32Array(256),this._font="",this._fontSize=0,this._weight="normal",this._weightBold="bold",this._measureElements=[],this._container=e.createElement("div"),this._container.classList.add("xterm-width-cache-measure-container"),this._container.setAttribute("aria-hidden","true"),this._container.style.whiteSpace="pre",this._container.style.fontKerning="none";const i=e.createElement("span");i.classList.add("xterm-char-measure-element");const s=e.createElement("span");s.classList.add("xterm-char-measure-element"),s.style.fontWeight="bold";const r=e.createElement("span");r.classList.add("xterm-char-measure-element"),r.style.fontStyle="italic";const n=e.createElement("span");n.classList.add("xterm-char-measure-element"),n.style.fontWeight="bold",n.style.fontStyle="italic",this._measureElements=[i,s,r,n],this._container.appendChild(i),this._container.appendChild(s),this._container.appendChild(r),this._container.appendChild(n),t.appendChild(this._container),this.clear()}dispose(){this._container.remove(),this._measureElements.length=0,this._holey=void 0}clear(){this._flat.fill(-9999),this._holey=new Map}setFont(e,t,i,s){e===this._font&&t===this._fontSize&&i===this._weight&&s===this._weightBold||(this._font=e,this._fontSize=t,this._weight=i,this._weightBold=s,this._container.style.fontFamily=this._font,this._container.style.fontSize=`${this._fontSize}px`,this._measureElements[0].style.fontWeight=`${i}`,this._measureElements[1].style.fontWeight=`${s}`,this._measureElements[2].style.fontWeight=`${i}`,this._measureElements[3].style.fontWeight=`${s}`,this.clear())}get(e,t,i){let s=0;if(!t&&!i&&1===e.length&&(s=e.charCodeAt(0))<256){if(-9999!==this._flat[s])return this._flat[s];const t=this._measure(e,0);return t>0&&(this._flat[s]=t),t}let r=e;t&&(r+="B"),i&&(r+="I");let n=this._holey.get(r);if(void 0===n){let s=0;t&&(s|=1),i&&(s|=2),n=this._measure(e,s),n>0&&this._holey.set(r,n)}return n}_measure(e,t){const i=this._measureElements[t];return i.textContent=e.repeat(32),i.offsetWidth/32}}},9176:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.INVERTED_DEFAULT_COLOR=void 0,t.INVERTED_DEFAULT_COLOR=257},6181:(e,t)=>{function i(e){return 57508<=e&&e<=57558}function s(e){return e>=128512&&e<=128591||e>=127744&&e<=128511||e>=128640&&e<=128767||e>=9728&&e<=9983||e>=9984&&e<=10175||e>=65024&&e<=65039||e>=129280&&e<=129535||e>=127462&&e<=127487}Object.defineProperty(t,"__esModule",{value:!0}),t.throwIfFalsy=function(e){if(!e)throw new Error("value must not be falsy");return e},t.isPowerlineGlyph=i,t.isRestrictedPowerlineGlyph=function(e){return 57520<=e&&e<=57527},t.isEmoji=s,t.allowRescaling=function(e,t,r,n){return 1===t&&r>Math.ceil(1.5*n)&&void 0!==e&&e>255&&!s(e)&&!i(e)&&!function(e){return 57344<=e&&e<=63743}(e)},t.treatGlyphAsBackgroundColor=function(e){return i(e)||function(e){return 9472<=e&&e<=9631}(e)},t.createRenderDimensions=function(){return{css:{canvas:{width:0,height:0},cell:{width:0,height:0}},device:{canvas:{width:0,height:0},cell:{width:0,height:0},char:{width:0,height:0,left:0,top:0}}}},t.computeNextVariantOffset=function(e,t,i=0){return(e-(2*Math.round(t)-i))%(2*Math.round(t))}},2274:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.createSelectionRenderModel=function(){return new i};class i{constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(e,t,i,s=!1){if(this.selectionStart=t,this.selectionEnd=i,!t||!i||t[0]===i[0]&&t[1]===i[1])return void this.clear();const r=e.buffers.active.ydisp,n=t[1]-r,o=i[1]-r,a=Math.max(n,0),l=Math.min(o,e.rows-1);a>=e.rows||l<0?this.clear():(this.hasSelection=!0,this.columnSelectMode=s,this.viewportStartRow=n,this.viewportEndRow=o,this.viewportCappedStartRow=a,this.viewportCappedEndRow=l,this.startCol=t[0],this.endCol=i[0])}isCellSelected(e,t,i){return!!this.hasSelection&&(i-=e.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?t>=this.startCol&&i>=this.viewportCappedStartRow&&t=this.viewportCappedStartRow&&t>=this.endCol&&i<=this.viewportCappedEndRow:i>this.viewportStartRow&&i=this.startCol&&t=this.startCol)}}},5959:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionModel=void 0,t.SelectionModel=class{constructor(e){this._bufferService=e,this.isSelectAllActive=!1,this.selectionStartLength=0}clearSelection(){this.selectionStart=void 0,this.selectionEnd=void 0,this.isSelectAllActive=!1,this.selectionStartLength=0}get finalSelectionStart(){return this.isSelectAllActive?[0,0]:this.selectionEnd&&this.selectionStart&&this.areSelectionValuesReversed()?this.selectionEnd:this.selectionStart}get finalSelectionEnd(){if(this.isSelectAllActive)return[this._bufferService.cols,this._bufferService.buffer.ybase+this._bufferService.rows-1];if(this.selectionStart){if(!this.selectionEnd||this.areSelectionValuesReversed()){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?e%this._bufferService.cols==0?[this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)-1]:[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[e,this.selectionStart[1]]}if(this.selectionStartLength&&this.selectionEnd[1]===this.selectionStart[1]){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[Math.max(e,this.selectionEnd[0]),this.selectionEnd[1]]}return this.selectionEnd}}areSelectionValuesReversed(){const e=this.selectionStart,t=this.selectionEnd;return!(!e||!t)&&(e[1]>t[1]||e[1]===t[1]&&e[0]>t[0])}handleTrim(e){return this.selectionStart&&(this.selectionStart[1]-=e),this.selectionEnd&&(this.selectionEnd[1]-=e),this.selectionEnd&&this.selectionEnd[1]<0?(this.clearSelection(),!0):(this.selectionStart&&this.selectionStart[1]<0&&(this.selectionStart[1]=0),!1)}}},4792:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharSizeService=void 0;const n=i(6501),o=i(7150),a=i(802);let l=class extends o.Disposable{get hasValidSize(){return this.width>0&&this.height>0}constructor(e,t,i){super(),this._optionsService=i,this.width=0,this.height=0,this._onCharSizeChange=this._register(new a.Emitter),this.onCharSizeChange=this._onCharSizeChange.event;try{this._measureStrategy=this._register(new d(this._optionsService))}catch{this._measureStrategy=this._register(new c(e,t,this._optionsService))}this._register(this._optionsService.onMultipleOptionChange(["fontFamily","fontSize"],(()=>this.measure())))}measure(){const e=this._measureStrategy.measure();e.width===this.width&&e.height===this.height||(this.width=e.width,this.height=e.height,this._onCharSizeChange.fire())}};t.CharSizeService=l,t.CharSizeService=l=s([r(2,n.IOptionsService)],l);class h extends o.Disposable{constructor(){super(...arguments),this._result={width:0,height:0}}_validateAndSet(e,t){void 0!==e&&e>0&&void 0!==t&&t>0&&(this._result.width=e,this._result.height=t)}}class c extends h{constructor(e,t,i){super(),this._document=e,this._parentElement=t,this._optionsService=i,this._measureElement=this._document.createElement("span"),this._measureElement.classList.add("xterm-char-measure-element"),this._measureElement.textContent="W".repeat(32),this._measureElement.setAttribute("aria-hidden","true"),this._measureElement.style.whiteSpace="pre",this._measureElement.style.fontKerning="none",this._parentElement.appendChild(this._measureElement)}measure(){return this._measureElement.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._measureElement.style.fontSize=`${this._optionsService.rawOptions.fontSize}px`,this._validateAndSet(Number(this._measureElement.offsetWidth)/32,Number(this._measureElement.offsetHeight)),this._result}}class d extends h{constructor(e){super(),this._optionsService=e,this._canvas=new OffscreenCanvas(100,100),this._ctx=this._canvas.getContext("2d");const t=this._ctx.measureText("W");if(!("width"in t&&"fontBoundingBoxAscent"in t&&"fontBoundingBoxDescent"in t))throw new Error("Required font metrics not supported")}measure(){this._ctx.font=`${this._optionsService.rawOptions.fontSize}px ${this._optionsService.rawOptions.fontFamily}`;const e=this._ctx.measureText("W");return this._validateAndSet(e.width,e.fontBoundingBoxAscent+e.fontBoundingBoxDescent),this._result}}},945:function(e,t,i){var s,r=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},n=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharacterJoinerService=t.JoinedCellData=void 0;const o=i(5451),a=i(8938),l=i(3055),h=i(6501);class c extends o.AttributeData{constructor(e,t,i){super(),this.content=0,this.combinedData="",this.fg=e.fg,this.bg=e.bg,this.combinedData=t,this._width=i}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(e){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.JoinedCellData=c;let d=s=class{constructor(e){this._bufferService=e,this._characterJoiners=[],this._nextCharacterJoinerId=0,this._workCell=new l.CellData}register(e){const t={id:this._nextCharacterJoinerId++,handler:e};return this._characterJoiners.push(t),t.id}deregister(e){for(let t=0;t1){const e=this._getJoinedRanges(s,o,n,t,r);for(let t=0;t1){const e=this._getJoinedRanges(s,o,n,t,r);for(let t=0;t{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreBrowserService=void 0;const s=i(802),r=i(7093),n=i(7150);class o extends n.Disposable{constructor(e,t,i){super(),this._textarea=e,this._window=t,this.mainDocument=i,this._isFocused=!1,this._cachedIsFocused=void 0,this._screenDprMonitor=this._register(new a(this._window)),this._onDprChange=this._register(new s.Emitter),this.onDprChange=this._onDprChange.event,this._onWindowChange=this._register(new s.Emitter),this.onWindowChange=this._onWindowChange.event,this._register(this.onWindowChange((e=>this._screenDprMonitor.setWindow(e)))),this._register(s.Event.forward(this._screenDprMonitor.onDprChange,this._onDprChange)),this._register((0,r.addDisposableListener)(this._textarea,"focus",(()=>this._isFocused=!0))),this._register((0,r.addDisposableListener)(this._textarea,"blur",(()=>this._isFocused=!1)))}get window(){return this._window}set window(e){this._window!==e&&(this._window=e,this._onWindowChange.fire(this._window))}get dpr(){return this.window.devicePixelRatio}get isFocused(){return void 0===this._cachedIsFocused&&(this._cachedIsFocused=this._isFocused&&this._textarea.ownerDocument.hasFocus(),queueMicrotask((()=>this._cachedIsFocused=void 0))),this._cachedIsFocused}}t.CoreBrowserService=o;class a extends n.Disposable{constructor(e){super(),this._parentWindow=e,this._windowResizeListener=this._register(new n.MutableDisposable),this._onDprChange=this._register(new s.Emitter),this.onDprChange=this._onDprChange.event,this._outerListener=()=>this._setDprAndFireIfDiffers(),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._updateDpr(),this._setWindowResizeListener(),this._register((0,n.toDisposable)((()=>this.clearListener())))}setWindow(e){this._parentWindow=e,this._setWindowResizeListener(),this._setDprAndFireIfDiffers()}_setWindowResizeListener(){this._windowResizeListener.value=(0,r.addDisposableListener)(this._parentWindow,"resize",(()=>this._setDprAndFireIfDiffers()))}_setDprAndFireIfDiffers(){this._parentWindow.devicePixelRatio!==this._currentDevicePixelRatio&&this._onDprChange.fire(this._parentWindow.devicePixelRatio),this._updateDpr()}_updateDpr(){this._outerListener&&(this._resolutionMediaMatchList?.removeListener(this._outerListener),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._resolutionMediaMatchList=this._parentWindow.matchMedia(`screen and (resolution: ${this._parentWindow.devicePixelRatio}dppx)`),this._resolutionMediaMatchList.addListener(this._outerListener))}clearListener(){this._resolutionMediaMatchList&&this._outerListener&&(this._resolutionMediaMatchList.removeListener(this._outerListener),this._resolutionMediaMatchList=void 0,this._outerListener=void 0)}}},9820:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.LinkProviderService=void 0;const s=i(7150);class r extends s.Disposable{constructor(){super(),this.linkProviders=[],this._register((0,s.toDisposable)((()=>this.linkProviders.length=0)))}registerLinkProvider(e){return this.linkProviders.push(e),{dispose:()=>{const t=this.linkProviders.indexOf(e);-1!==t&&this.linkProviders.splice(t,1)}}}}t.LinkProviderService=r},9784:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.MouseService=void 0;const n=i(7098),o=i(5251);let a=class{constructor(e,t){this._renderService=e,this._charSizeService=t}getCoords(e,t,i,s,r){return(0,o.getCoords)(window,e,t,i,s,this._charSizeService.hasValidSize,this._renderService.dimensions.css.cell.width,this._renderService.dimensions.css.cell.height,r)}getMouseReportCoords(e,t){const i=(0,o.getCoordsRelativeToElement)(window,e,t);if(this._charSizeService.hasValidSize)return i[0]=Math.min(Math.max(i[0],0),this._renderService.dimensions.css.canvas.width-1),i[1]=Math.min(Math.max(i[1],0),this._renderService.dimensions.css.canvas.height-1),{col:Math.floor(i[0]/this._renderService.dimensions.css.cell.width),row:Math.floor(i[1]/this._renderService.dimensions.css.cell.height),x:Math.floor(i[0]),y:Math.floor(i[1])}}};t.MouseService=a,t.MouseService=a=s([r(0,n.IRenderService),r(1,n.ICharSizeService)],a)},5783:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.RenderService=void 0;const n=i(4852),o=i(7098),a=i(7150),l=i(6168),h=i(6501),c=i(802);let d=class extends a.Disposable{get dimensions(){return this._renderer.value.dimensions}constructor(e,t,i,s,r,o,h,d,_){super(),this._rowCount=e,this._optionsService=i,this._charSizeService=s,this._coreService=r,this._coreBrowserService=d,this._renderer=this._register(new a.MutableDisposable),this._pausedResizeTask=new l.DebouncedIdleTask,this._observerDisposable=this._register(new a.MutableDisposable),this._isPaused=!1,this._needsFullRefresh=!1,this._isNextRenderRedrawOnly=!0,this._needsSelectionRefresh=!1,this._canvasWidth=0,this._canvasHeight=0,this._selectionState={start:void 0,end:void 0,columnSelectMode:!1},this._onDimensionsChange=this._register(new c.Emitter),this.onDimensionsChange=this._onDimensionsChange.event,this._onRenderedViewportChange=this._register(new c.Emitter),this.onRenderedViewportChange=this._onRenderedViewportChange.event,this._onRender=this._register(new c.Emitter),this.onRender=this._onRender.event,this._onRefreshRequest=this._register(new c.Emitter),this.onRefreshRequest=this._onRefreshRequest.event,this._renderDebouncer=new n.RenderDebouncer(((e,t)=>this._renderRows(e,t)),this._coreBrowserService),this._register(this._renderDebouncer),this._syncOutputHandler=new u(this._coreBrowserService,this._coreService,(()=>this._fullRefresh())),this._register((0,a.toDisposable)((()=>this._syncOutputHandler.dispose()))),this._register(this._coreBrowserService.onDprChange((()=>this.handleDevicePixelRatioChange()))),this._register(h.onResize((()=>this._fullRefresh()))),this._register(h.buffers.onBufferActivate((()=>this._renderer.value?.clear()))),this._register(this._optionsService.onOptionChange((()=>this._handleOptionsChanged()))),this._register(this._charSizeService.onCharSizeChange((()=>this.handleCharSizeChanged()))),this._register(o.onDecorationRegistered((()=>this._fullRefresh()))),this._register(o.onDecorationRemoved((()=>this._fullRefresh()))),this._register(this._optionsService.onMultipleOptionChange(["customGlyphs","drawBoldTextInBrightColors","letterSpacing","lineHeight","fontFamily","fontSize","fontWeight","fontWeightBold","minimumContrastRatio","rescaleOverlappingGlyphs"],(()=>{this.clear(),this.handleResize(h.cols,h.rows),this._fullRefresh()}))),this._register(this._optionsService.onMultipleOptionChange(["cursorBlink","cursorStyle"],(()=>this.refreshRows(h.buffer.y,h.buffer.y,!0)))),this._register(_.onChangeColors((()=>this._fullRefresh()))),this._registerIntersectionObserver(this._coreBrowserService.window,t),this._register(this._coreBrowserService.onWindowChange((e=>this._registerIntersectionObserver(e,t))))}_registerIntersectionObserver(e,t){if("IntersectionObserver"in e){const i=new e.IntersectionObserver((e=>this._handleIntersectionChange(e[e.length-1])),{threshold:0});i.observe(t),this._observerDisposable.value=(0,a.toDisposable)((()=>i.disconnect()))}}_handleIntersectionChange(e){this._isPaused=void 0===e.isIntersecting?0===e.intersectionRatio:!e.isIntersecting,this._isPaused||this._charSizeService.hasValidSize||this._charSizeService.measure(),!this._isPaused&&this._needsFullRefresh&&(this._pausedResizeTask.flush(),this.refreshRows(0,this._rowCount-1),this._needsFullRefresh=!1)}refreshRows(e,t,i=!1){if(this._isPaused)return void(this._needsFullRefresh=!0);if(this._coreService.decPrivateModes.synchronizedOutput)return void this._syncOutputHandler.bufferRows(e,t);const s=this._syncOutputHandler.flush();s&&(e=Math.min(e,s.start),t=Math.max(t,s.end)),i||(this._isNextRenderRedrawOnly=!1),this._renderDebouncer.refresh(e,t,this._rowCount)}_renderRows(e,t){this._renderer.value&&(this._coreService.decPrivateModes.synchronizedOutput?this._syncOutputHandler.bufferRows(e,t):(e=Math.min(e,this._rowCount-1),t=Math.min(t,this._rowCount-1),this._renderer.value.renderRows(e,t),this._needsSelectionRefresh&&(this._renderer.value.handleSelectionChanged(this._selectionState.start,this._selectionState.end,this._selectionState.columnSelectMode),this._needsSelectionRefresh=!1),this._isNextRenderRedrawOnly||this._onRenderedViewportChange.fire({start:e,end:t}),this._onRender.fire({start:e,end:t}),this._isNextRenderRedrawOnly=!0))}resize(e,t){this._rowCount=t,this._fireOnCanvasResize()}_handleOptionsChanged(){this._renderer.value&&(this.refreshRows(0,this._rowCount-1),this._fireOnCanvasResize())}_fireOnCanvasResize(){this._renderer.value&&(this._renderer.value.dimensions.css.canvas.width===this._canvasWidth&&this._renderer.value.dimensions.css.canvas.height===this._canvasHeight||this._onDimensionsChange.fire(this._renderer.value.dimensions))}hasRenderer(){return!!this._renderer.value}setRenderer(e){this._renderer.value=e,this._renderer.value&&(this._renderer.value.onRequestRedraw((e=>this.refreshRows(e.start,e.end,!0))),this._needsSelectionRefresh=!0,this._fullRefresh())}addRefreshCallback(e){return this._renderDebouncer.addRefreshCallback(e)}_fullRefresh(){this._isPaused?this._needsFullRefresh=!0:this.refreshRows(0,this._rowCount-1)}clearTextureAtlas(){this._renderer.value&&(this._renderer.value.clearTextureAtlas?.(),this._fullRefresh())}handleDevicePixelRatioChange(){this._charSizeService.measure(),this._renderer.value&&(this._renderer.value.handleDevicePixelRatioChange(),this.refreshRows(0,this._rowCount-1))}handleResize(e,t){this._renderer.value&&(this._isPaused?this._pausedResizeTask.set((()=>this._renderer.value?.handleResize(e,t))):this._renderer.value.handleResize(e,t),this._fullRefresh())}handleCharSizeChanged(){this._renderer.value?.handleCharSizeChanged()}handleBlur(){this._renderer.value?.handleBlur()}handleFocus(){this._renderer.value?.handleFocus()}handleSelectionChanged(e,t,i){this._selectionState.start=e,this._selectionState.end=t,this._selectionState.columnSelectMode=i,this._renderer.value?.handleSelectionChanged(e,t,i)}handleCursorMove(){this._renderer.value?.handleCursorMove()}clear(){this._renderer.value?.clear()}};t.RenderService=d,t.RenderService=d=s([r(2,h.IOptionsService),r(3,o.ICharSizeService),r(4,h.ICoreService),r(5,h.IDecorationService),r(6,h.IBufferService),r(7,o.ICoreBrowserService),r(8,o.IThemeService)],d);class u{constructor(e,t,i){this._coreBrowserService=e,this._coreService=t,this._onTimeout=i,this._start=0,this._end=0,this._isBuffering=!1}bufferRows(e,t){this._isBuffering?(this._start=Math.min(this._start,e),this._end=Math.max(this._end,t)):(this._start=e,this._end=t,this._isBuffering=!0),void 0===this._timeout&&(this._timeout=this._coreBrowserService.window.setTimeout((()=>{this._timeout=void 0,this._coreService.decPrivateModes.synchronizedOutput=!1,this._onTimeout()}),1e3))}flush(){if(void 0!==this._timeout&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0),!this._isBuffering)return;const e={start:this._start,end:this._end};return this._isBuffering=!1,e}dispose(){void 0!==this._timeout&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0)}}},2079:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionService=void 0;const n=i(5251),o=i(9686),a=i(5959),l=i(7098),h=i(7150),c=i(701),d=i(9384),u=i(3055),_=i(6501),f=i(802),p=String.fromCharCode(160),g=new RegExp(p,"g");let m=class extends h.Disposable{constructor(e,t,i,s,r,n,o,l,c){super(),this._element=e,this._screenElement=t,this._linkifier=i,this._bufferService=s,this._coreService=r,this._mouseService=n,this._optionsService=o,this._renderService=l,this._coreBrowserService=c,this._dragScrollAmount=0,this._enabled=!0,this._workCell=new u.CellData,this._mouseDownTimeStamp=0,this._oldHasSelection=!1,this._oldSelectionStart=void 0,this._oldSelectionEnd=void 0,this._onLinuxMouseSelection=this._register(new f.Emitter),this.onLinuxMouseSelection=this._onLinuxMouseSelection.event,this._onRedrawRequest=this._register(new f.Emitter),this.onRequestRedraw=this._onRedrawRequest.event,this._onSelectionChange=this._register(new f.Emitter),this.onSelectionChange=this._onSelectionChange.event,this._onRequestScrollLines=this._register(new f.Emitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this._mouseMoveListener=e=>this._handleMouseMove(e),this._mouseUpListener=e=>this._handleMouseUp(e),this._coreService.onUserInput((()=>{this.hasSelection&&this.clearSelection()})),this._trimListener=this._bufferService.buffer.lines.onTrim((e=>this._handleTrim(e))),this._register(this._bufferService.buffers.onBufferActivate((e=>this._handleBufferActivate(e)))),this.enable(),this._model=new a.SelectionModel(this._bufferService),this._activeSelectionMode=0,this._register((0,h.toDisposable)((()=>{this._removeMouseDownListeners()}))),this._register(this._bufferService.onResize((e=>{e.rowsChanged&&this.clearSelection()})))}reset(){this.clearSelection()}disable(){this.clearSelection(),this._enabled=!1}enable(){this._enabled=!0}get selectionStart(){return this._model.finalSelectionStart}get selectionEnd(){return this._model.finalSelectionEnd}get hasSelection(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;return!(!e||!t||e[0]===t[0]&&e[1]===t[1])}get selectionText(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;if(!e||!t)return"";const i=this._bufferService.buffer,s=[];if(3===this._activeSelectionMode){if(e[0]===t[0])return"";const r=e[0]e.replace(g," "))).join(c.isWindows?"\r\n":"\n")}clearSelection(){this._model.clearSelection(),this._removeMouseDownListeners(),this.refresh(),this._onSelectionChange.fire()}refresh(e){this._refreshAnimationFrame||(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._refresh()))),c.isLinux&&e&&this.selectionText.length&&this._onLinuxMouseSelection.fire(this.selectionText)}_refresh(){this._refreshAnimationFrame=void 0,this._onRedrawRequest.fire({start:this._model.finalSelectionStart,end:this._model.finalSelectionEnd,columnSelectMode:3===this._activeSelectionMode})}_isClickInSelection(e){const t=this._getMouseBufferCoords(e),i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!!(i&&s&&t)&&this._areCoordsInSelection(t,i,s)}isCellInSelection(e,t){const i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!(!i||!s)&&this._areCoordsInSelection([e,t],i,s)}_areCoordsInSelection(e,t,i){return e[1]>t[1]&&e[1]=t[0]&&e[0]=t[0]}_selectWordAtCursor(e,t){const i=this._linkifier.currentLink?.link?.range;if(i)return this._model.selectionStart=[i.start.x-1,i.start.y-1],this._model.selectionStartLength=(0,d.getRangeLength)(i,this._bufferService.cols),this._model.selectionEnd=void 0,!0;const s=this._getMouseBufferCoords(e);return!!s&&(this._selectWordAt(s,t),this._model.selectionEnd=void 0,!0)}selectAll(){this._model.isSelectAllActive=!0,this.refresh(),this._onSelectionChange.fire()}selectLines(e,t){this._model.clearSelection(),e=Math.max(e,0),t=Math.min(t,this._bufferService.buffer.lines.length-1),this._model.selectionStart=[0,e],this._model.selectionEnd=[this._bufferService.cols,t],this.refresh(),this._onSelectionChange.fire()}_handleTrim(e){this._model.handleTrim(e)&&this.refresh()}_getMouseBufferCoords(e){const t=this._mouseService.getCoords(e,this._screenElement,this._bufferService.cols,this._bufferService.rows,!0);if(t)return t[0]--,t[1]--,t[1]+=this._bufferService.buffer.ydisp,t}_getMouseEventScrollAmount(e){let t=(0,n.getCoordsRelativeToElement)(this._coreBrowserService.window,e,this._screenElement)[1];const i=this._renderService.dimensions.css.canvas.height;return t>=0&&t<=i?0:(t>i&&(t-=i),t=Math.min(Math.max(t,-50),50),t/=50,t/Math.abs(t)+Math.round(14*t))}shouldForceSelection(e){return c.isMac?e.altKey&&this._optionsService.rawOptions.macOptionClickForcesSelection:e.shiftKey}handleMouseDown(e){if(this._mouseDownTimeStamp=e.timeStamp,(2!==e.button||!this.hasSelection)&&0===e.button){if(!this._enabled){if(!this.shouldForceSelection(e))return;e.stopPropagation()}e.preventDefault(),this._dragScrollAmount=0,this._enabled&&e.shiftKey?this._handleIncrementalClick(e):1===e.detail?this._handleSingleClick(e):2===e.detail?this._handleDoubleClick(e):3===e.detail&&this._handleTripleClick(e),this._addMouseDownListeners(),this.refresh(!0)}}_addMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.addEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.addEventListener("mouseup",this._mouseUpListener)),this._dragScrollIntervalTimer=this._coreBrowserService.window.setInterval((()=>this._dragScroll()),50)}_removeMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.removeEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.removeEventListener("mouseup",this._mouseUpListener)),this._coreBrowserService.window.clearInterval(this._dragScrollIntervalTimer),this._dragScrollIntervalTimer=void 0}_handleIncrementalClick(e){this._model.selectionStart&&(this._model.selectionEnd=this._getMouseBufferCoords(e))}_handleSingleClick(e){if(this._model.selectionStartLength=0,this._model.isSelectAllActive=!1,this._activeSelectionMode=this.shouldColumnSelect(e)?3:0,this._model.selectionStart=this._getMouseBufferCoords(e),!this._model.selectionStart)return;this._model.selectionEnd=void 0;const t=this._bufferService.buffer.lines.get(this._model.selectionStart[1]);t&&t.length!==this._model.selectionStart[0]&&0===t.hasWidth(this._model.selectionStart[0])&&this._model.selectionStart[0]++}_handleDoubleClick(e){this._selectWordAtCursor(e,!0)&&(this._activeSelectionMode=1)}_handleTripleClick(e){const t=this._getMouseBufferCoords(e);t&&(this._activeSelectionMode=2,this._selectLineAt(t[1]))}shouldColumnSelect(e){return e.altKey&&!(c.isMac&&this._optionsService.rawOptions.macOptionClickForcesSelection)}_handleMouseMove(e){if(e.stopImmediatePropagation(),!this._model.selectionStart)return;const t=this._model.selectionEnd?[this._model.selectionEnd[0],this._model.selectionEnd[1]]:null;if(this._model.selectionEnd=this._getMouseBufferCoords(e),!this._model.selectionEnd)return void this.refresh(!0);2===this._activeSelectionMode?this._model.selectionEnd[1]0?this._model.selectionEnd[0]=this._bufferService.cols:this._dragScrollAmount<0&&(this._model.selectionEnd[0]=0));const i=this._bufferService.buffer;if(this._model.selectionEnd[1]0?(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=this._bufferService.cols),this._model.selectionEnd[1]=Math.min(e.ydisp+this._bufferService.rows,e.lines.length-1)):(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=0),this._model.selectionEnd[1]=e.ydisp),this.refresh()}}_handleMouseUp(e){const t=e.timeStamp-this._mouseDownTimeStamp;if(this._removeMouseDownListeners(),this.selectionText.length<=1&&t<500&&e.altKey&&this._optionsService.rawOptions.altClickMovesCursor){if(this._bufferService.buffer.ybase===this._bufferService.buffer.ydisp){const t=this._mouseService.getCoords(e,this._element,this._bufferService.cols,this._bufferService.rows,!1);if(t&&void 0!==t[0]&&void 0!==t[1]){const e=(0,o.moveToCellSequence)(t[0]-1,t[1]-1,this._bufferService,this._coreService.decPrivateModes.applicationCursorKeys);this._coreService.triggerDataEvent(e,!0)}}}else this._fireEventIfSelectionChanged()}_fireEventIfSelectionChanged(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd,i=!(!e||!t||e[0]===t[0]&&e[1]===t[1]);i?e&&t&&(this._oldSelectionStart&&this._oldSelectionEnd&&e[0]===this._oldSelectionStart[0]&&e[1]===this._oldSelectionStart[1]&&t[0]===this._oldSelectionEnd[0]&&t[1]===this._oldSelectionEnd[1]||this._fireOnSelectionChange(e,t,i)):this._oldHasSelection&&this._fireOnSelectionChange(e,t,i)}_fireOnSelectionChange(e,t,i){this._oldSelectionStart=e,this._oldSelectionEnd=t,this._oldHasSelection=i,this._onSelectionChange.fire()}_handleBufferActivate(e){this.clearSelection(),this._trimListener.dispose(),this._trimListener=e.activeBuffer.lines.onTrim((e=>this._handleTrim(e)))}_convertViewportColToCharacterIndex(e,t){let i=t;for(let s=0;t>=s;s++){const r=e.loadCell(s,this._workCell).getChars().length;0===this._workCell.getWidth()?i--:r>1&&t!==s&&(i+=r-1)}return i}setSelection(e,t,i){this._model.clearSelection(),this._removeMouseDownListeners(),this._model.selectionStart=[e,t],this._model.selectionStartLength=i,this.refresh(),this._fireEventIfSelectionChanged()}rightClickSelect(e){this._isClickInSelection(e)||(this._selectWordAtCursor(e,!1)&&this.refresh(!0),this._fireEventIfSelectionChanged())}_getWordAt(e,t,i=!0,s=!0){if(e[0]>=this._bufferService.cols)return;const r=this._bufferService.buffer,n=r.lines.get(e[1]);if(!n)return;const o=r.translateBufferLineToString(e[1],!1);let a=this._convertViewportColToCharacterIndex(n,e[0]),l=a;const h=e[0]-a;let c=0,d=0,u=0,_=0;if(" "===o.charAt(a)){for(;a>0&&" "===o.charAt(a-1);)a--;for(;l1&&(_+=s-1,l+=s-1);t>0&&a>0&&!this._isCharWordSeparator(n.loadCell(t-1,this._workCell));){n.loadCell(t-1,this._workCell);const e=this._workCell.getChars().length;0===this._workCell.getWidth()?(c++,t--):e>1&&(u+=e-1,a-=e-1),a--,t--}for(;i1&&(_+=e-1,l+=e-1),l++,i++}}l++;let f=a+h-c+u,p=Math.min(this._bufferService.cols,l-a+c+d-u-_);if(t||""!==o.slice(a,l).trim()){if(i&&0===f&&32!==n.getCodePoint(0)){const t=r.lines.get(e[1]-1);if(t&&n.isWrapped&&32!==t.getCodePoint(this._bufferService.cols-1)){const t=this._getWordAt([this._bufferService.cols-1,e[1]-1],!1,!0,!1);if(t){const e=this._bufferService.cols-t.start;f-=e,p+=e}}}if(s&&f+p===this._bufferService.cols&&32!==n.getCodePoint(this._bufferService.cols-1)){const t=r.lines.get(e[1]+1);if(t?.isWrapped&&32!==t.getCodePoint(0)){const t=this._getWordAt([0,e[1]+1],!1,!1,!0);t&&(p+=t.length)}}return{start:f,length:p}}}_selectWordAt(e,t){const i=this._getWordAt(e,t);if(i){for(;i.start<0;)i.start+=this._bufferService.cols,e[1]--;this._model.selectionStart=[i.start,e[1]],this._model.selectionStartLength=i.length}}_selectToWordAt(e){const t=this._getWordAt(e,!0);if(t){let i=e[1];for(;t.start<0;)t.start+=this._bufferService.cols,i--;if(!this._model.areSelectionValuesReversed())for(;t.start+t.length>this._bufferService.cols;)t.length-=this._bufferService.cols,i++;this._model.selectionEnd=[this._model.areSelectionValuesReversed()?t.start:t.start+t.length,i]}}_isCharWordSeparator(e){return 0!==e.getWidth()&&this._optionsService.rawOptions.wordSeparator.indexOf(e.getChars())>=0}_selectLineAt(e){const t=this._bufferService.buffer.getWrappedRangeForLine(e),i={start:{x:0,y:t.first},end:{x:this._bufferService.cols-1,y:t.last}};this._model.selectionStart=[0,t.first],this._model.selectionEnd=void 0,this._model.selectionStartLength=(0,d.getRangeLength)(i,this._bufferService.cols)}};t.SelectionService=m,t.SelectionService=m=s([r(3,_.IBufferService),r(4,_.ICoreService),r(5,l.IMouseService),r(6,_.IOptionsService),r(7,l.IRenderService),r(8,l.ICoreBrowserService)],m)},7098:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ILinkProviderService=t.IThemeService=t.ICharacterJoinerService=t.ISelectionService=t.IRenderService=t.IMouseService=t.ICoreBrowserService=t.ICharSizeService=void 0;const s=i(6201);t.ICharSizeService=(0,s.createDecorator)("CharSizeService"),t.ICoreBrowserService=(0,s.createDecorator)("CoreBrowserService"),t.IMouseService=(0,s.createDecorator)("MouseService"),t.IRenderService=(0,s.createDecorator)("RenderService"),t.ISelectionService=(0,s.createDecorator)("SelectionService"),t.ICharacterJoinerService=(0,s.createDecorator)("CharacterJoinerService"),t.IThemeService=(0,s.createDecorator)("ThemeService"),t.ILinkProviderService=(0,s.createDecorator)("LinkProviderService")},9078:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.ThemeService=void 0;const n=i(7174),o=i(9302),a=i(4103),l=i(7150),h=i(6501),c=i(802),d=a.css.toColor("#ffffff"),u=a.css.toColor("#000000"),_=a.css.toColor("#ffffff"),f=u,p={css:"rgba(255, 255, 255, 0.3)",rgba:4294967117},g=d;let m=class extends l.Disposable{get colors(){return this._colors}constructor(e){super(),this._optionsService=e,this._contrastCache=new n.ColorContrastCache,this._halfContrastCache=new n.ColorContrastCache,this._onChangeColors=this._register(new c.Emitter),this.onChangeColors=this._onChangeColors.event,this._colors={foreground:d,background:u,cursor:_,cursorAccent:f,selectionForeground:void 0,selectionBackgroundTransparent:p,selectionBackgroundOpaque:a.color.blend(u,p),selectionInactiveBackgroundTransparent:p,selectionInactiveBackgroundOpaque:a.color.blend(u,p),scrollbarSliderBackground:a.color.opacity(d,.2),scrollbarSliderHoverBackground:a.color.opacity(d,.4),scrollbarSliderActiveBackground:a.color.opacity(d,.5),overviewRulerBorder:d,ansi:o.DEFAULT_ANSI_COLORS.slice(),contrastCache:this._contrastCache,halfContrastCache:this._halfContrastCache},this._updateRestoreColors(),this._setTheme(this._optionsService.rawOptions.theme),this._register(this._optionsService.onSpecificOptionChange("minimumContrastRatio",(()=>this._contrastCache.clear()))),this._register(this._optionsService.onSpecificOptionChange("theme",(()=>this._setTheme(this._optionsService.rawOptions.theme))))}_setTheme(e={}){const t=this._colors;if(t.foreground=v(e.foreground,d),t.background=v(e.background,u),t.cursor=a.color.blend(t.background,v(e.cursor,_)),t.cursorAccent=a.color.blend(t.background,v(e.cursorAccent,f)),t.selectionBackgroundTransparent=v(e.selectionBackground,p),t.selectionBackgroundOpaque=a.color.blend(t.background,t.selectionBackgroundTransparent),t.selectionInactiveBackgroundTransparent=v(e.selectionInactiveBackground,t.selectionBackgroundTransparent),t.selectionInactiveBackgroundOpaque=a.color.blend(t.background,t.selectionInactiveBackgroundTransparent),t.selectionForeground=e.selectionForeground?v(e.selectionForeground,a.NULL_COLOR):void 0,t.selectionForeground===a.NULL_COLOR&&(t.selectionForeground=void 0),a.color.isOpaque(t.selectionBackgroundTransparent)){const e=.3;t.selectionBackgroundTransparent=a.color.opacity(t.selectionBackgroundTransparent,e)}if(a.color.isOpaque(t.selectionInactiveBackgroundTransparent)){const e=.3;t.selectionInactiveBackgroundTransparent=a.color.opacity(t.selectionInactiveBackgroundTransparent,e)}if(t.scrollbarSliderBackground=v(e.scrollbarSliderBackground,a.color.opacity(t.foreground,.2)),t.scrollbarSliderHoverBackground=v(e.scrollbarSliderHoverBackground,a.color.opacity(t.foreground,.4)),t.scrollbarSliderActiveBackground=v(e.scrollbarSliderActiveBackground,a.color.opacity(t.foreground,.5)),t.overviewRulerBorder=v(e.overviewRulerBorder,g),t.ansi=o.DEFAULT_ANSI_COLORS.slice(),t.ansi[0]=v(e.black,o.DEFAULT_ANSI_COLORS[0]),t.ansi[1]=v(e.red,o.DEFAULT_ANSI_COLORS[1]),t.ansi[2]=v(e.green,o.DEFAULT_ANSI_COLORS[2]),t.ansi[3]=v(e.yellow,o.DEFAULT_ANSI_COLORS[3]),t.ansi[4]=v(e.blue,o.DEFAULT_ANSI_COLORS[4]),t.ansi[5]=v(e.magenta,o.DEFAULT_ANSI_COLORS[5]),t.ansi[6]=v(e.cyan,o.DEFAULT_ANSI_COLORS[6]),t.ansi[7]=v(e.white,o.DEFAULT_ANSI_COLORS[7]),t.ansi[8]=v(e.brightBlack,o.DEFAULT_ANSI_COLORS[8]),t.ansi[9]=v(e.brightRed,o.DEFAULT_ANSI_COLORS[9]),t.ansi[10]=v(e.brightGreen,o.DEFAULT_ANSI_COLORS[10]),t.ansi[11]=v(e.brightYellow,o.DEFAULT_ANSI_COLORS[11]),t.ansi[12]=v(e.brightBlue,o.DEFAULT_ANSI_COLORS[12]),t.ansi[13]=v(e.brightMagenta,o.DEFAULT_ANSI_COLORS[13]),t.ansi[14]=v(e.brightCyan,o.DEFAULT_ANSI_COLORS[14]),t.ansi[15]=v(e.brightWhite,o.DEFAULT_ANSI_COLORS[15]),e.extendedAnsi){const i=Math.min(t.ansi.length-16,e.extendedAnsi.length);for(let s=0;s{Object.defineProperty(t,"__esModule",{value:!0}),t.CircularList=void 0;const s=i(7150),r=i(802);class n extends s.Disposable{constructor(e){super(),this._maxLength=e,this.onDeleteEmitter=this._register(new r.Emitter),this.onDelete=this.onDeleteEmitter.event,this.onInsertEmitter=this._register(new r.Emitter),this.onInsert=this.onInsertEmitter.event,this.onTrimEmitter=this._register(new r.Emitter),this.onTrim=this.onTrimEmitter.event,this._array=new Array(this._maxLength),this._startIndex=0,this._length=0}get maxLength(){return this._maxLength}set maxLength(e){if(this._maxLength===e)return;const t=new Array(e);for(let i=0;ithis._length)for(let t=this._length;t=e;t--)this._array[this._getCyclicIndex(t+i.length)]=this._array[this._getCyclicIndex(t)];for(let t=0;tthis._maxLength){const e=this._length+i.length-this._maxLength;this._startIndex+=e,this._length=this._maxLength,this.onTrimEmitter.fire(e)}else this._length+=i.length}trimStart(e){e>this._length&&(e=this._length),this._startIndex+=e,this._length-=e,this.onTrimEmitter.fire(e)}shiftElements(e,t,i){if(!(t<=0)){if(e<0||e>=this._length)throw new Error("start argument out of range");if(e+i<0)throw new Error("Cannot shift elements in list beyond index 0");if(i>0){for(let s=t-1;s>=0;s--)this.set(e+s+i,this.get(e+s));const s=e+t+i-this._length;if(s>0)for(this._length+=s;this._length>this._maxLength;)this._length--,this._startIndex++,this.onTrimEmitter.fire(1)}else for(let s=0;s{Object.defineProperty(t,"__esModule",{value:!0}),t.clone=function e(t,i=5){if("object"!=typeof t)return t;const s=Array.isArray(t)?[]:{};for(const r in t)s[r]=i<=1?t[r]:t[r]&&e(t[r],i-1);return s}},4103:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.rgba=t.rgb=t.css=t.color=t.channels=t.NULL_COLOR=void 0,t.toPaddedHex=d,t.contrastRatio=u;let i=0,s=0,r=0,n=0;var o,a,l,h,c;function d(e){const t=e.toString(16);return t.length<2?"0"+t:t}function u(e,t){return e>>0},e.toColor=function(t,i,s,r){return{css:e.toCss(t,i,s,r),rgba:e.toRgba(t,i,s,r)}}}(o||(t.channels=o={})),function(e){function t(e,t){return n=Math.round(255*t),[i,s,r]=c.toChannels(e.rgba),{css:o.toCss(i,s,r,n),rgba:o.toRgba(i,s,r,n)}}e.blend=function(e,t){if(n=(255&t.rgba)/255,1===n)return{css:t.css,rgba:t.rgba};const a=t.rgba>>24&255,l=t.rgba>>16&255,h=t.rgba>>8&255,c=e.rgba>>24&255,d=e.rgba>>16&255,u=e.rgba>>8&255;return i=c+Math.round((a-c)*n),s=d+Math.round((l-d)*n),r=u+Math.round((h-u)*n),{css:o.toCss(i,s,r),rgba:o.toRgba(i,s,r)}},e.isOpaque=function(e){return!(255&~e.rgba)},e.ensureContrastRatio=function(e,t,i){const s=c.ensureContrastRatio(e.rgba,t.rgba,i);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},e.opaque=function(e){const t=(255|e.rgba)>>>0;return[i,s,r]=c.toChannels(t),{css:o.toCss(i,s,r),rgba:t}},e.opacity=t,e.multiplyOpacity=function(e,i){return n=255&e.rgba,t(e,n*i/255)},e.toColorRGB=function(e){return[e.rgba>>24&255,e.rgba>>16&255,e.rgba>>8&255]}}(a||(t.color=a={})),function(e){let t,a;try{const e=document.createElement("canvas");e.width=1,e.height=1;const i=e.getContext("2d",{willReadFrequently:!0});i&&(t=i,t.globalCompositeOperation="copy",a=t.createLinearGradient(0,0,1,1))}catch{}e.toColor=function(e){if(e.match(/#[\da-f]{3,8}/i))switch(e.length){case 4:return i=parseInt(e.slice(1,2).repeat(2),16),s=parseInt(e.slice(2,3).repeat(2),16),r=parseInt(e.slice(3,4).repeat(2),16),o.toColor(i,s,r);case 5:return i=parseInt(e.slice(1,2).repeat(2),16),s=parseInt(e.slice(2,3).repeat(2),16),r=parseInt(e.slice(3,4).repeat(2),16),n=parseInt(e.slice(4,5).repeat(2),16),o.toColor(i,s,r,n);case 7:return{css:e,rgba:(parseInt(e.slice(1),16)<<8|255)>>>0};case 9:return{css:e,rgba:parseInt(e.slice(1),16)>>>0}}const l=e.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(l)return i=parseInt(l[1]),s=parseInt(l[2]),r=parseInt(l[3]),n=Math.round(255*(void 0===l[5]?1:parseFloat(l[5]))),o.toColor(i,s,r,n);if(!t||!a)throw new Error("css.toColor: Unsupported css format");if(t.fillStyle=a,t.fillStyle=e,"string"!=typeof t.fillStyle)throw new Error("css.toColor: Unsupported css format");if(t.fillRect(0,0,1,1),[i,s,r,n]=t.getImageData(0,0,1,1).data,255!==n)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(i,s,r,n),css:e}}}(l||(t.css=l={})),function(e){function t(e,t,i){const s=e/255,r=t/255,n=i/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(r<=.03928?r/12.92:Math.pow((r+.055)/1.055,2.4))+.0722*(n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4))}e.relativeLuminance=function(e){return t(e>>16&255,e>>8&255,255&e)},e.relativeLuminance2=t}(h||(t.rgb=h={})),function(e){function t(e,t,i){const s=e>>24&255,r=e>>16&255,n=e>>8&255;let o=t>>24&255,a=t>>16&255,l=t>>8&255,c=u(h.relativeLuminance2(o,a,l),h.relativeLuminance2(s,r,n));for(;c0||a>0||l>0);)o-=Math.max(0,Math.ceil(.1*o)),a-=Math.max(0,Math.ceil(.1*a)),l-=Math.max(0,Math.ceil(.1*l)),c=u(h.relativeLuminance2(o,a,l),h.relativeLuminance2(s,r,n));return(o<<24|a<<16|l<<8|255)>>>0}function a(e,t,i){const s=e>>24&255,r=e>>16&255,n=e>>8&255;let o=t>>24&255,a=t>>16&255,l=t>>8&255,c=u(h.relativeLuminance2(o,a,l),h.relativeLuminance2(s,r,n));for(;c>>0}e.blend=function(e,t){if(n=(255&t)/255,1===n)return t;const a=t>>24&255,l=t>>16&255,h=t>>8&255,c=e>>24&255,d=e>>16&255,u=e>>8&255;return i=c+Math.round((a-c)*n),s=d+Math.round((l-d)*n),r=u+Math.round((h-u)*n),o.toRgba(i,s,r)},e.ensureContrastRatio=function(e,i,s){const r=h.relativeLuminance(e>>8),n=h.relativeLuminance(i>>8);if(u(r,n)>8));if(ou(r,h.relativeLuminance(t>>8))?n:t}return n}const o=a(e,i,s),l=u(r,h.relativeLuminance(o>>8));if(lu(r,h.relativeLuminance(n>>8))?o:n}return o}},e.reduceLuminance=t,e.increaseLuminance=a,e.toChannels=function(e){return[e>>24&255,e>>16&255,e>>8&255,255&e]}}(c||(t.rgba=c={}))},5777:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreTerminal=void 0;const s=i(6501),r=i(6025),n=i(7276),o=i(9640),a=i(56),l=i(4071),h=i(7792),c=i(6415),d=i(5746),u=i(5882),_=i(2486),f=i(3562),p=i(8811),g=i(802),m=i(7150);let v=!1;class S extends m.Disposable{get onScroll(){return this._onScrollApi||(this._onScrollApi=this._register(new g.Emitter),this._onScroll.event((e=>{this._onScrollApi?.fire(e.position)}))),this._onScrollApi.event}get cols(){return this._bufferService.cols}get rows(){return this._bufferService.rows}get buffers(){return this._bufferService.buffers}get options(){return this.optionsService.options}set options(e){for(const t in e)this.optionsService.options[t]=e[t]}constructor(e){super(),this._windowsWrappingHeuristics=this._register(new m.MutableDisposable),this._onBinary=this._register(new g.Emitter),this.onBinary=this._onBinary.event,this._onData=this._register(new g.Emitter),this.onData=this._onData.event,this._onLineFeed=this._register(new g.Emitter),this.onLineFeed=this._onLineFeed.event,this._onResize=this._register(new g.Emitter),this.onResize=this._onResize.event,this._onWriteParsed=this._register(new g.Emitter),this.onWriteParsed=this._onWriteParsed.event,this._onScroll=this._register(new g.Emitter),this._instantiationService=new r.InstantiationService,this.optionsService=this._register(new a.OptionsService(e)),this._instantiationService.setService(s.IOptionsService,this.optionsService),this._bufferService=this._register(this._instantiationService.createInstance(o.BufferService)),this._instantiationService.setService(s.IBufferService,this._bufferService),this._logService=this._register(this._instantiationService.createInstance(n.LogService)),this._instantiationService.setService(s.ILogService,this._logService),this.coreService=this._register(this._instantiationService.createInstance(l.CoreService)),this._instantiationService.setService(s.ICoreService,this.coreService),this.coreMouseService=this._register(this._instantiationService.createInstance(h.CoreMouseService)),this._instantiationService.setService(s.ICoreMouseService,this.coreMouseService),this.unicodeService=this._register(this._instantiationService.createInstance(c.UnicodeService)),this._instantiationService.setService(s.IUnicodeService,this.unicodeService),this._charsetService=this._instantiationService.createInstance(d.CharsetService),this._instantiationService.setService(s.ICharsetService,this._charsetService),this._oscLinkService=this._instantiationService.createInstance(p.OscLinkService),this._instantiationService.setService(s.IOscLinkService,this._oscLinkService),this._inputHandler=this._register(new _.InputHandler(this._bufferService,this._charsetService,this.coreService,this._logService,this.optionsService,this._oscLinkService,this.coreMouseService,this.unicodeService)),this._register(g.Event.forward(this._inputHandler.onLineFeed,this._onLineFeed)),this._register(this._inputHandler),this._register(g.Event.forward(this._bufferService.onResize,this._onResize)),this._register(g.Event.forward(this.coreService.onData,this._onData)),this._register(g.Event.forward(this.coreService.onBinary,this._onBinary)),this._register(this.coreService.onRequestScrollToBottom((()=>this.scrollToBottom(!0)))),this._register(this.coreService.onUserInput((()=>this._writeBuffer.handleUserInput()))),this._register(this.optionsService.onMultipleOptionChange(["windowsMode","windowsPty"],(()=>this._handleWindowsPtyOptionChange()))),this._register(this._bufferService.onScroll((()=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)}))),this._writeBuffer=this._register(new f.WriteBuffer(((e,t)=>this._inputHandler.parse(e,t)))),this._register(g.Event.forward(this._writeBuffer.onWriteParsed,this._onWriteParsed))}write(e,t){this._writeBuffer.write(e,t)}writeSync(e,t){this._logService.logLevel<=s.LogLevelEnum.WARN&&!v&&(this._logService.warn("writeSync is unreliable and will be removed soon."),v=!0),this._writeBuffer.writeSync(e,t)}input(e,t=!0){this.coreService.triggerDataEvent(e,t)}resize(e,t){isNaN(e)||isNaN(t)||(e=Math.max(e,o.MINIMUM_COLS),t=Math.max(t,o.MINIMUM_ROWS),this._bufferService.resize(e,t))}scroll(e,t=!1){this._bufferService.scroll(e,t)}scrollLines(e,t){this._bufferService.scrollLines(e,t)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){const t=e-this._bufferService.buffer.ydisp;0!==t&&this.scrollLines(t)}registerEscHandler(e,t){return this._inputHandler.registerEscHandler(e,t)}registerDcsHandler(e,t){return this._inputHandler.registerDcsHandler(e,t)}registerCsiHandler(e,t){return this._inputHandler.registerCsiHandler(e,t)}registerOscHandler(e,t){return this._inputHandler.registerOscHandler(e,t)}_setup(){this._handleWindowsPtyOptionChange()}reset(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.coreMouseService.reset()}_handleWindowsPtyOptionChange(){let e=!1;const t=this.optionsService.rawOptions.windowsPty;t&&void 0!==t.buildNumber&&void 0!==t.buildNumber?e=!!("conpty"===t.backend&&t.buildNumber<21376):this.optionsService.rawOptions.windowsMode&&(e=!0),e?this._enableWindowsWrappingHeuristics():this._windowsWrappingHeuristics.clear()}_enableWindowsWrappingHeuristics(){if(!this._windowsWrappingHeuristics.value){const e=[];e.push(this.onLineFeed(u.updateWindowsModeWrappedState.bind(null,this._bufferService))),e.push(this.registerCsiHandler({final:"H"},(()=>((0,u.updateWindowsModeWrappedState)(this._bufferService),!1)))),this._windowsWrappingHeuristics.value=(0,m.toDisposable)((()=>{for(const t of e)t.dispose()}))}}}t.CoreTerminal=S},2486:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.InputHandler=t.WindowsOptionsReportType=void 0,t.isValidColorIndex=R;const n=i(3534),o=i(6760),a=i(6717),l=i(7150),h=i(726),c=i(6107),d=i(8938),u=i(3055),_=i(5451),f=i(6501),p=i(6415),g=i(1346),m=i(9823),v=i(8693),S=i(802),b={"(":0,")":1,"*":2,"+":3,"-":1,".":2},C=131072;function y(e,t){if(e>24)return t.setWinLines||!1;switch(e){case 1:return!!t.restoreWin;case 2:return!!t.minimizeWin;case 3:return!!t.setWinPosition;case 4:return!!t.setWinSizePixels;case 5:return!!t.raiseWin;case 6:return!!t.lowerWin;case 7:return!!t.refreshWin;case 8:return!!t.setWinSizeChars;case 9:return!!t.maximizeWin;case 10:return!!t.fullscreenWin;case 11:return!!t.getWinState;case 13:return!!t.getWinPosition;case 14:return!!t.getWinSizePixels;case 15:return!!t.getScreenSizePixels;case 16:return!!t.getCellSizePixels;case 18:return!!t.getWinSizeChars;case 19:return!!t.getScreenSizeChars;case 20:return!!t.getIconTitle;case 21:return!!t.getWinTitle;case 22:return!!t.pushTitle;case 23:return!!t.popTitle;case 24:return!!t.setWinLines}return!1}var w;!function(e){e[e.GET_WIN_SIZE_PIXELS=0]="GET_WIN_SIZE_PIXELS",e[e.GET_CELL_SIZE_PIXELS=1]="GET_CELL_SIZE_PIXELS"}(w||(t.WindowsOptionsReportType=w={}));let E=0;class D extends l.Disposable{getAttrData(){return this._curAttrData}constructor(e,t,i,s,r,l,d,u,_=new a.EscapeSequenceParser){super(),this._bufferService=e,this._charsetService=t,this._coreService=i,this._logService=s,this._optionsService=r,this._oscLinkService=l,this._coreMouseService=d,this._unicodeService=u,this._parser=_,this._parseBuffer=new Uint32Array(4096),this._stringDecoder=new h.StringToUtf32,this._utf8Decoder=new h.Utf8ToUtf32,this._windowTitle="",this._iconName="",this._windowTitleStack=[],this._iconNameStack=[],this._curAttrData=c.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=c.DEFAULT_ATTR_DATA.clone(),this._onRequestBell=this._register(new S.Emitter),this.onRequestBell=this._onRequestBell.event,this._onRequestRefreshRows=this._register(new S.Emitter),this.onRequestRefreshRows=this._onRequestRefreshRows.event,this._onRequestReset=this._register(new S.Emitter),this.onRequestReset=this._onRequestReset.event,this._onRequestSendFocus=this._register(new S.Emitter),this.onRequestSendFocus=this._onRequestSendFocus.event,this._onRequestSyncScrollBar=this._register(new S.Emitter),this.onRequestSyncScrollBar=this._onRequestSyncScrollBar.event,this._onRequestWindowsOptionsReport=this._register(new S.Emitter),this.onRequestWindowsOptionsReport=this._onRequestWindowsOptionsReport.event,this._onA11yChar=this._register(new S.Emitter),this.onA11yChar=this._onA11yChar.event,this._onA11yTab=this._register(new S.Emitter),this.onA11yTab=this._onA11yTab.event,this._onCursorMove=this._register(new S.Emitter),this.onCursorMove=this._onCursorMove.event,this._onLineFeed=this._register(new S.Emitter),this.onLineFeed=this._onLineFeed.event,this._onScroll=this._register(new S.Emitter),this.onScroll=this._onScroll.event,this._onTitleChange=this._register(new S.Emitter),this.onTitleChange=this._onTitleChange.event,this._onColor=this._register(new S.Emitter),this.onColor=this._onColor.event,this._parseStack={paused:!1,cursorStartX:0,cursorStartY:0,decodedLength:0,position:0},this._specialColors=[256,257,258],this._register(this._parser),this._dirtyRowTracker=new L(this._bufferService),this._activeBuffer=this._bufferService.buffer,this._register(this._bufferService.buffers.onBufferActivate((e=>this._activeBuffer=e.activeBuffer))),this._parser.setCsiHandlerFallback(((e,t)=>{this._logService.debug("Unknown CSI code: ",{identifier:this._parser.identToString(e),params:t.toArray()})})),this._parser.setEscHandlerFallback((e=>{this._logService.debug("Unknown ESC code: ",{identifier:this._parser.identToString(e)})})),this._parser.setExecuteHandlerFallback((e=>{this._logService.debug("Unknown EXECUTE code: ",{code:e})})),this._parser.setOscHandlerFallback(((e,t,i)=>{this._logService.debug("Unknown OSC code: ",{identifier:e,action:t,data:i})})),this._parser.setDcsHandlerFallback(((e,t,i)=>{"HOOK"===t&&(i=i.toArray()),this._logService.debug("Unknown DCS code: ",{identifier:this._parser.identToString(e),action:t,payload:i})})),this._parser.setPrintHandler(((e,t,i)=>this.print(e,t,i))),this._parser.registerCsiHandler({final:"@"},(e=>this.insertChars(e))),this._parser.registerCsiHandler({intermediates:" ",final:"@"},(e=>this.scrollLeft(e))),this._parser.registerCsiHandler({final:"A"},(e=>this.cursorUp(e))),this._parser.registerCsiHandler({intermediates:" ",final:"A"},(e=>this.scrollRight(e))),this._parser.registerCsiHandler({final:"B"},(e=>this.cursorDown(e))),this._parser.registerCsiHandler({final:"C"},(e=>this.cursorForward(e))),this._parser.registerCsiHandler({final:"D"},(e=>this.cursorBackward(e))),this._parser.registerCsiHandler({final:"E"},(e=>this.cursorNextLine(e))),this._parser.registerCsiHandler({final:"F"},(e=>this.cursorPrecedingLine(e))),this._parser.registerCsiHandler({final:"G"},(e=>this.cursorCharAbsolute(e))),this._parser.registerCsiHandler({final:"H"},(e=>this.cursorPosition(e))),this._parser.registerCsiHandler({final:"I"},(e=>this.cursorForwardTab(e))),this._parser.registerCsiHandler({final:"J"},(e=>this.eraseInDisplay(e,!1))),this._parser.registerCsiHandler({prefix:"?",final:"J"},(e=>this.eraseInDisplay(e,!0))),this._parser.registerCsiHandler({final:"K"},(e=>this.eraseInLine(e,!1))),this._parser.registerCsiHandler({prefix:"?",final:"K"},(e=>this.eraseInLine(e,!0))),this._parser.registerCsiHandler({final:"L"},(e=>this.insertLines(e))),this._parser.registerCsiHandler({final:"M"},(e=>this.deleteLines(e))),this._parser.registerCsiHandler({final:"P"},(e=>this.deleteChars(e))),this._parser.registerCsiHandler({final:"S"},(e=>this.scrollUp(e))),this._parser.registerCsiHandler({final:"T"},(e=>this.scrollDown(e))),this._parser.registerCsiHandler({final:"X"},(e=>this.eraseChars(e))),this._parser.registerCsiHandler({final:"Z"},(e=>this.cursorBackwardTab(e))),this._parser.registerCsiHandler({final:"`"},(e=>this.charPosAbsolute(e))),this._parser.registerCsiHandler({final:"a"},(e=>this.hPositionRelative(e))),this._parser.registerCsiHandler({final:"b"},(e=>this.repeatPrecedingCharacter(e))),this._parser.registerCsiHandler({final:"c"},(e=>this.sendDeviceAttributesPrimary(e))),this._parser.registerCsiHandler({prefix:">",final:"c"},(e=>this.sendDeviceAttributesSecondary(e))),this._parser.registerCsiHandler({final:"d"},(e=>this.linePosAbsolute(e))),this._parser.registerCsiHandler({final:"e"},(e=>this.vPositionRelative(e))),this._parser.registerCsiHandler({final:"f"},(e=>this.hVPosition(e))),this._parser.registerCsiHandler({final:"g"},(e=>this.tabClear(e))),this._parser.registerCsiHandler({final:"h"},(e=>this.setMode(e))),this._parser.registerCsiHandler({prefix:"?",final:"h"},(e=>this.setModePrivate(e))),this._parser.registerCsiHandler({final:"l"},(e=>this.resetMode(e))),this._parser.registerCsiHandler({prefix:"?",final:"l"},(e=>this.resetModePrivate(e))),this._parser.registerCsiHandler({final:"m"},(e=>this.charAttributes(e))),this._parser.registerCsiHandler({final:"n"},(e=>this.deviceStatus(e))),this._parser.registerCsiHandler({prefix:"?",final:"n"},(e=>this.deviceStatusPrivate(e))),this._parser.registerCsiHandler({intermediates:"!",final:"p"},(e=>this.softReset(e))),this._parser.registerCsiHandler({intermediates:" ",final:"q"},(e=>this.setCursorStyle(e))),this._parser.registerCsiHandler({final:"r"},(e=>this.setScrollRegion(e))),this._parser.registerCsiHandler({final:"s"},(e=>this.saveCursor(e))),this._parser.registerCsiHandler({final:"t"},(e=>this.windowOptions(e))),this._parser.registerCsiHandler({final:"u"},(e=>this.restoreCursor(e))),this._parser.registerCsiHandler({intermediates:"'",final:"}"},(e=>this.insertColumns(e))),this._parser.registerCsiHandler({intermediates:"'",final:"~"},(e=>this.deleteColumns(e))),this._parser.registerCsiHandler({intermediates:'"',final:"q"},(e=>this.selectProtected(e))),this._parser.registerCsiHandler({intermediates:"$",final:"p"},(e=>this.requestMode(e,!0))),this._parser.registerCsiHandler({prefix:"?",intermediates:"$",final:"p"},(e=>this.requestMode(e,!1))),this._parser.setExecuteHandler(n.C0.BEL,(()=>this.bell())),this._parser.setExecuteHandler(n.C0.LF,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.VT,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.FF,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.CR,(()=>this.carriageReturn())),this._parser.setExecuteHandler(n.C0.BS,(()=>this.backspace())),this._parser.setExecuteHandler(n.C0.HT,(()=>this.tab())),this._parser.setExecuteHandler(n.C0.SO,(()=>this.shiftOut())),this._parser.setExecuteHandler(n.C0.SI,(()=>this.shiftIn())),this._parser.setExecuteHandler(n.C1.IND,(()=>this.index())),this._parser.setExecuteHandler(n.C1.NEL,(()=>this.nextLine())),this._parser.setExecuteHandler(n.C1.HTS,(()=>this.tabSet())),this._parser.registerOscHandler(0,new g.OscHandler((e=>(this.setTitle(e),this.setIconName(e),!0)))),this._parser.registerOscHandler(1,new g.OscHandler((e=>this.setIconName(e)))),this._parser.registerOscHandler(2,new g.OscHandler((e=>this.setTitle(e)))),this._parser.registerOscHandler(4,new g.OscHandler((e=>this.setOrReportIndexedColor(e)))),this._parser.registerOscHandler(8,new g.OscHandler((e=>this.setHyperlink(e)))),this._parser.registerOscHandler(10,new g.OscHandler((e=>this.setOrReportFgColor(e)))),this._parser.registerOscHandler(11,new g.OscHandler((e=>this.setOrReportBgColor(e)))),this._parser.registerOscHandler(12,new g.OscHandler((e=>this.setOrReportCursorColor(e)))),this._parser.registerOscHandler(104,new g.OscHandler((e=>this.restoreIndexedColor(e)))),this._parser.registerOscHandler(110,new g.OscHandler((e=>this.restoreFgColor(e)))),this._parser.registerOscHandler(111,new g.OscHandler((e=>this.restoreBgColor(e)))),this._parser.registerOscHandler(112,new g.OscHandler((e=>this.restoreCursorColor(e)))),this._parser.registerEscHandler({final:"7"},(()=>this.saveCursor())),this._parser.registerEscHandler({final:"8"},(()=>this.restoreCursor())),this._parser.registerEscHandler({final:"D"},(()=>this.index())),this._parser.registerEscHandler({final:"E"},(()=>this.nextLine())),this._parser.registerEscHandler({final:"H"},(()=>this.tabSet())),this._parser.registerEscHandler({final:"M"},(()=>this.reverseIndex())),this._parser.registerEscHandler({final:"="},(()=>this.keypadApplicationMode())),this._parser.registerEscHandler({final:">"},(()=>this.keypadNumericMode())),this._parser.registerEscHandler({final:"c"},(()=>this.fullReset())),this._parser.registerEscHandler({final:"n"},(()=>this.setgLevel(2))),this._parser.registerEscHandler({final:"o"},(()=>this.setgLevel(3))),this._parser.registerEscHandler({final:"|"},(()=>this.setgLevel(3))),this._parser.registerEscHandler({final:"}"},(()=>this.setgLevel(2))),this._parser.registerEscHandler({final:"~"},(()=>this.setgLevel(1))),this._parser.registerEscHandler({intermediates:"%",final:"@"},(()=>this.selectDefaultCharset())),this._parser.registerEscHandler({intermediates:"%",final:"G"},(()=>this.selectDefaultCharset()));for(const e in o.CHARSETS)this._parser.registerEscHandler({intermediates:"(",final:e},(()=>this.selectCharset("("+e))),this._parser.registerEscHandler({intermediates:")",final:e},(()=>this.selectCharset(")"+e))),this._parser.registerEscHandler({intermediates:"*",final:e},(()=>this.selectCharset("*"+e))),this._parser.registerEscHandler({intermediates:"+",final:e},(()=>this.selectCharset("+"+e))),this._parser.registerEscHandler({intermediates:"-",final:e},(()=>this.selectCharset("-"+e))),this._parser.registerEscHandler({intermediates:".",final:e},(()=>this.selectCharset("."+e))),this._parser.registerEscHandler({intermediates:"/",final:e},(()=>this.selectCharset("/"+e)));this._parser.registerEscHandler({intermediates:"#",final:"8"},(()=>this.screenAlignmentPattern())),this._parser.setErrorHandler((e=>(this._logService.error("Parsing error: ",e),e))),this._parser.registerDcsHandler({intermediates:"$",final:"q"},new m.DcsHandler(((e,t)=>this.requestStatusString(e,t))))}_preserveStack(e,t,i,s){this._parseStack.paused=!0,this._parseStack.cursorStartX=e,this._parseStack.cursorStartY=t,this._parseStack.decodedLength=i,this._parseStack.position=s}_logSlowResolvingAsync(e){this._logService.logLevel<=f.LogLevelEnum.WARN&&Promise.race([e,new Promise(((e,t)=>setTimeout((()=>t("#SLOW_TIMEOUT")),5e3)))]).catch((e=>{if("#SLOW_TIMEOUT"!==e)throw e;console.warn("async parser handler taking longer than 5000 ms")}))}_getCurrentLinkId(){return this._curAttrData.extended.urlId}parse(e,t){let i,s=this._activeBuffer.x,r=this._activeBuffer.y,n=0;const o=this._parseStack.paused;if(o){if(i=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,t))return this._logSlowResolvingAsync(i),i;s=this._parseStack.cursorStartX,r=this._parseStack.cursorStartY,this._parseStack.paused=!1,e.length>C&&(n=this._parseStack.position+C)}if(this._logService.logLevel<=f.LogLevelEnum.DEBUG&&this._logService.debug("parsing data "+("string"==typeof e?` "${e}"`:` "${Array.prototype.map.call(e,(e=>String.fromCharCode(e))).join("")}"`)),this._logService.logLevel===f.LogLevelEnum.TRACE&&this._logService.trace("parsing data (codes)","string"==typeof e?e.split("").map((e=>e.charCodeAt(0))):e),this._parseBuffer.lengthC)for(let t=n;t0&&2===f.getWidth(this._activeBuffer.x-1)&&f.setCellFromCodepoint(this._activeBuffer.x-1,0,1,_);let g=this._parser.precedingJoinState;for(let m=t;ma)if(l){const e=f;let t=this._activeBuffer.x-v;for(this._activeBuffer.x=v,this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData(),!0)):(this._activeBuffer.y>=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),f=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y),v>0&&f instanceof c.BufferLine&&f.copyCellsFrom(e,t,0,v,!1);t=0;)f.setCellFromCodepoint(this._activeBuffer.x++,0,0,_)}else if(u&&(f.insertCells(this._activeBuffer.x,r-v,this._activeBuffer.getNullCell(_)),2===f.getWidth(a-1)&&f.setCellFromCodepoint(a-1,d.NULL_CELL_CODE,d.NULL_CELL_WIDTH,_)),f.setCellFromCodepoint(this._activeBuffer.x++,s,r,_),r>0)for(;--r;)f.setCellFromCodepoint(this._activeBuffer.x++,0,0,_)}this._parser.precedingJoinState=g,this._activeBuffer.x0&&0===f.getWidth(this._activeBuffer.x)&&!f.hasContent(this._activeBuffer.x)&&f.setCellFromCodepoint(this._activeBuffer.x,0,1,_),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}registerCsiHandler(e,t){return"t"!==e.final||e.prefix||e.intermediates?this._parser.registerCsiHandler(e,t):this._parser.registerCsiHandler(e,(e=>!y(e.params[0],this._optionsService.rawOptions.windowOptions)||t(e)))}registerDcsHandler(e,t){return this._parser.registerDcsHandler(e,new m.DcsHandler(t))}registerEscHandler(e,t){return this._parser.registerEscHandler(e,t)}registerOscHandler(e,t){return this._parser.registerOscHandler(e,new g.OscHandler(t))}bell(){return this._onRequestBell.fire(),!0}lineFeed(){return this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows?this._activeBuffer.y=this._bufferService.rows-1:this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0}carriageReturn(){return this._activeBuffer.x=0,!0}backspace(){if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(0===this._activeBuffer.x&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)?.isWrapped){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;const e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);e.hasWidth(this._activeBuffer.x)&&!e.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0}tab(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;const e=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-e),!0}shiftOut(){return this._charsetService.setgLevel(1),!0}shiftIn(){return this._charsetService.setgLevel(0),!0}_restrictCursor(e=this._bufferService.cols-1){this._activeBuffer.x=Math.min(e,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_setCursor(e,t){this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=e,this._activeBuffer.y=this._activeBuffer.scrollTop+t):(this._activeBuffer.x=e,this._activeBuffer.y=t),this._restrictCursor(),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_moveCursor(e,t){this._restrictCursor(),this._setCursor(this._activeBuffer.x+e,this._activeBuffer.y+t)}cursorUp(e){const t=this._activeBuffer.y-this._activeBuffer.scrollTop;return t>=0?this._moveCursor(0,-Math.min(t,e.params[0]||1)):this._moveCursor(0,-(e.params[0]||1)),!0}cursorDown(e){const t=this._activeBuffer.scrollBottom-this._activeBuffer.y;return t>=0?this._moveCursor(0,Math.min(t,e.params[0]||1)):this._moveCursor(0,e.params[0]||1),!0}cursorForward(e){return this._moveCursor(e.params[0]||1,0),!0}cursorBackward(e){return this._moveCursor(-(e.params[0]||1),0),!0}cursorNextLine(e){return this.cursorDown(e),this._activeBuffer.x=0,!0}cursorPrecedingLine(e){return this.cursorUp(e),this._activeBuffer.x=0,!0}cursorCharAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}cursorPosition(e){return this._setCursor(e.length>=2?(e.params[1]||1)-1:0,(e.params[0]||1)-1),!0}charPosAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}hPositionRelative(e){return this._moveCursor(e.params[0]||1,0),!0}linePosAbsolute(e){return this._setCursor(this._activeBuffer.x,(e.params[0]||1)-1),!0}vPositionRelative(e){return this._moveCursor(0,e.params[0]||1),!0}hVPosition(e){return this.cursorPosition(e),!0}tabClear(e){const t=e.params[0];return 0===t?delete this._activeBuffer.tabs[this._activeBuffer.x]:3===t&&(this._activeBuffer.tabs={}),!0}cursorForwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0}cursorBackwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0}selectProtected(e){const t=e.params[0];return 1===t&&(this._curAttrData.bg|=536870912),2!==t&&0!==t||(this._curAttrData.bg&=-536870913),!0}_eraseInBufferLine(e,t,i,s=!1,r=!1){const n=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);n.replaceCells(t,i,this._activeBuffer.getNullCell(this._eraseAttrData()),r),s&&(n.isWrapped=!1)}_resetBufferLine(e,t=!1){const i=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);i&&(i.fill(this._activeBuffer.getNullCell(this._eraseAttrData()),t),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+e),i.isWrapped=!1)}eraseInDisplay(e,t=!1){let i;switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:for(i=this._activeBuffer.y,this._dirtyRowTracker.markDirty(i),this._eraseInBufferLine(i++,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);i=this._bufferService.cols&&(this._activeBuffer.lines.get(i+1).isWrapped=!1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0);break;case 2:if(this._optionsService.rawOptions.scrollOnEraseInDisplay){for(i=this._bufferService.rows,this._dirtyRowTracker.markRangeDirty(0,i-1);i--;){const e=this._activeBuffer.lines.get(this._activeBuffer.ybase+i);if(e?.getTrimmedLength())break}for(;i>=0;i--)this._bufferService.scroll(this._eraseAttrData())}else{for(i=this._bufferService.rows,this._dirtyRowTracker.markDirty(i-1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0)}break;case 3:const e=this._activeBuffer.lines.length-this._bufferService.rows;e>0&&(this._activeBuffer.lines.trimStart(e),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-e,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-e,0),this._onScroll.fire(0))}return!0}eraseInLine(e,t=!1){switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1,t);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0,t)}return this._dirtyRowTracker.markDirty(this._activeBuffer.y),!0}insertLines(e){this._restrictCursor();let t=e.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.y65535?2:1}let l=a;for(let e=1;e0||(this._is("xterm")||this._is("rxvt-unicode")||this._is("screen")?this._coreService.triggerDataEvent(n.C0.ESC+"[?1;2c"):this._is("linux")&&this._coreService.triggerDataEvent(n.C0.ESC+"[?6c")),!0}sendDeviceAttributesSecondary(e){return e.params[0]>0||(this._is("xterm")?this._coreService.triggerDataEvent(n.C0.ESC+"[>0;276;0c"):this._is("rxvt-unicode")?this._coreService.triggerDataEvent(n.C0.ESC+"[>85;95;0c"):this._is("linux")?this._coreService.triggerDataEvent(e.params[0]+"c"):this._is("screen")&&this._coreService.triggerDataEvent(n.C0.ESC+"[>83;40003;0c")),!0}_is(e){return 0===(this._optionsService.rawOptions.termName+"").indexOf(e)}setMode(e){for(let t=0;te?1:2,_=e.params[0];return f=_,p=t?2===_?4:4===_?u(o.modes.insertMode):12===_?3:20===_?u(d.convertEol):0:1===_?u(i.applicationCursorKeys):3===_?d.windowOptions.setWinLines?80===l?2:132===l?1:0:0:6===_?u(i.origin):7===_?u(i.wraparound):8===_?3:9===_?u("X10"===s):12===_?u(d.cursorBlink):25===_?u(!o.isCursorHidden):45===_?u(i.reverseWraparound):66===_?u(i.applicationKeypad):67===_?4:1e3===_?u("VT200"===s):1002===_?u("DRAG"===s):1003===_?u("ANY"===s):1004===_?u(i.sendFocus):1005===_?4:1006===_?u("SGR"===r):1015===_?4:1016===_?u("SGR_PIXELS"===r):1048===_?1:47===_||1047===_||1049===_?u(h===c):2004===_?u(i.bracketedPasteMode):2026===_?u(i.synchronizedOutput):0,o.triggerDataEvent(`${n.C0.ESC}[${t?"":"?"}${f};${p}$y`),!0;var f,p}_updateAttrColor(e,t,i,s,r){return 2===t?(e|=50331648,e&=-16777216,e|=_.AttributeData.fromColorRGB([i,s,r])):5===t&&(e&=-50331904,e|=33554432|255&i),e}_extractColor(e,t,i){const s=[0,0,-1,0,0,0];let r=0,n=0;do{if(s[n+r]=e.params[t+n],e.hasSubParams(t+n)){const i=e.getSubParams(t+n);let o=0;do{5===s[1]&&(r=1),s[n+o+1+r]=i[o]}while(++o=2||2===s[1]&&n+r>=5)break;s[1]&&(r=1)}while(++n+t5)&&(e=1),t.extended.underlineStyle=e,t.fg|=268435456,0===e&&(t.fg&=-268435457),t.updateExtended()}_processSGR0(e){e.fg=c.DEFAULT_ATTR_DATA.fg,e.bg=c.DEFAULT_ATTR_DATA.bg,e.extended=e.extended.clone(),e.extended.underlineStyle=0,e.extended.underlineColor&=-67108864,e.updateExtended()}charAttributes(e){if(1===e.length&&0===e.params[0])return this._processSGR0(this._curAttrData),!0;const t=e.length;let i;const s=this._curAttrData;for(let r=0;r=30&&i<=37?(s.fg&=-50331904,s.fg|=16777216|i-30):i>=40&&i<=47?(s.bg&=-50331904,s.bg|=16777216|i-40):i>=90&&i<=97?(s.fg&=-50331904,s.fg|=16777224|i-90):i>=100&&i<=107?(s.bg&=-50331904,s.bg|=16777224|i-100):0===i?this._processSGR0(s):1===i?s.fg|=134217728:3===i?s.bg|=67108864:4===i?(s.fg|=268435456,this._processUnderline(e.hasSubParams(r)?e.getSubParams(r)[0]:1,s)):5===i?s.fg|=536870912:7===i?s.fg|=67108864:8===i?s.fg|=1073741824:9===i?s.fg|=2147483648:2===i?s.bg|=134217728:21===i?this._processUnderline(2,s):22===i?(s.fg&=-134217729,s.bg&=-134217729):23===i?s.bg&=-67108865:24===i?(s.fg&=-268435457,this._processUnderline(0,s)):25===i?s.fg&=-536870913:27===i?s.fg&=-67108865:28===i?s.fg&=-1073741825:29===i?s.fg&=2147483647:39===i?(s.fg&=-67108864,s.fg|=16777215&c.DEFAULT_ATTR_DATA.fg):49===i?(s.bg&=-67108864,s.bg|=16777215&c.DEFAULT_ATTR_DATA.bg):38===i||48===i||58===i?r+=this._extractColor(e,r,s):53===i?s.bg|=1073741824:55===i?s.bg&=-1073741825:59===i?(s.extended=s.extended.clone(),s.extended.underlineColor=-1,s.updateExtended()):100===i?(s.fg&=-67108864,s.fg|=16777215&c.DEFAULT_ATTR_DATA.fg,s.bg&=-67108864,s.bg|=16777215&c.DEFAULT_ATTR_DATA.bg):this._logService.debug("Unknown SGR attribute: %d.",i);return!0}deviceStatus(e){switch(e.params[0]){case 5:this._coreService.triggerDataEvent(`${n.C0.ESC}[0n`);break;case 6:const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${n.C0.ESC}[${e};${t}R`)}return!0}deviceStatusPrivate(e){if(6===e.params[0]){const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${n.C0.ESC}[?${e};${t}R`)}return!0}softReset(e){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=c.DEFAULT_ATTR_DATA.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0}setCursorStyle(e){const t=0===e.length?1:e.params[0];if(0===t)this._coreService.decPrivateModes.cursorStyle=void 0,this._coreService.decPrivateModes.cursorBlink=void 0;else{switch(t){case 1:case 2:this._coreService.decPrivateModes.cursorStyle="block";break;case 3:case 4:this._coreService.decPrivateModes.cursorStyle="underline";break;case 5:case 6:this._coreService.decPrivateModes.cursorStyle="bar"}const e=t%2==1;this._coreService.decPrivateModes.cursorBlink=e}return!0}setScrollRegion(e){const t=e.params[0]||1;let i;return(e.length<2||(i=e.params[1])>this._bufferService.rows||0===i)&&(i=this._bufferService.rows),i>t&&(this._activeBuffer.scrollTop=t-1,this._activeBuffer.scrollBottom=i-1,this._setCursor(0,0)),!0}windowOptions(e){if(!y(e.params[0],this._optionsService.rawOptions.windowOptions))return!0;const t=e.length>1?e.params[1]:0;switch(e.params[0]){case 14:2!==t&&this._onRequestWindowsOptionsReport.fire(w.GET_WIN_SIZE_PIXELS);break;case 16:this._onRequestWindowsOptionsReport.fire(w.GET_CELL_SIZE_PIXELS);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(`${n.C0.ESC}[8;${this._bufferService.rows};${this._bufferService.cols}t`);break;case 22:0!==t&&2!==t||(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>10&&this._windowTitleStack.shift()),0!==t&&1!==t||(this._iconNameStack.push(this._iconName),this._iconNameStack.length>10&&this._iconNameStack.shift());break;case 23:0!==t&&2!==t||this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),0!==t&&1!==t||this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop())}return!0}saveCursor(e){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,!0}restoreCursor(e){return this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg,this._charsetService.charset=this._savedCharset,this._activeBuffer.savedCharset&&(this._charsetService.charset=this._activeBuffer.savedCharset),this._restrictCursor(),!0}setTitle(e){return this._windowTitle=e,this._onTitleChange.fire(e),!0}setIconName(e){return this._iconName=e,!0}setOrReportIndexedColor(e){const t=[],i=e.split(";");for(;i.length>1;){const e=i.shift(),s=i.shift();if(/^\d+$/.exec(e)){const i=parseInt(e);if(R(i))if("?"===s)t.push({type:0,index:i});else{const e=(0,v.parseColor)(s);e&&t.push({type:1,index:i,color:e})}}}return t.length&&this._onColor.fire(t),!0}setHyperlink(e){const t=e.indexOf(";");if(-1===t)return!0;const i=e.slice(0,t).trim(),s=e.slice(t+1);return s?this._createHyperlink(i,s):!i.trim()&&this._finishHyperlink()}_createHyperlink(e,t){this._getCurrentLinkId()&&this._finishHyperlink();const i=e.split(":");let s;const r=i.findIndex((e=>e.startsWith("id=")));return-1!==r&&(s=i[r].slice(3)||void 0),this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=this._oscLinkService.registerLink({id:s,uri:t}),this._curAttrData.updateExtended(),!0}_finishHyperlink(){return this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=0,this._curAttrData.updateExtended(),!0}_setOrReportSpecialColor(e,t){const i=e.split(";");for(let e=0;e=this._specialColors.length);++e,++t)if("?"===i[e])this._onColor.fire([{type:0,index:this._specialColors[t]}]);else{const s=(0,v.parseColor)(i[e]);s&&this._onColor.fire([{type:1,index:this._specialColors[t],color:s}])}return!0}setOrReportFgColor(e){return this._setOrReportSpecialColor(e,0)}setOrReportBgColor(e){return this._setOrReportSpecialColor(e,1)}setOrReportCursorColor(e){return this._setOrReportSpecialColor(e,2)}restoreIndexedColor(e){if(!e)return this._onColor.fire([{type:2}]),!0;const t=[],i=e.split(";");for(let e=0;e=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0}tabSet(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0}reverseIndex(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){const e=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,e,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0}fullReset(){return this._parser.reset(),this._onRequestReset.fire(),!0}reset(){this._curAttrData=c.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=c.DEFAULT_ATTR_DATA.clone()}_eraseAttrData(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=67108863&this._curAttrData.bg,this._eraseAttrDataInternal}setgLevel(e){return this._charsetService.setgLevel(e),!0}screenAlignmentPattern(){const e=new u.CellData;e.content=1<<22|"E".charCodeAt(0),e.fg=this._curAttrData.fg,e.bg=this._curAttrData.bg,this._setCursor(0,0);for(let t=0;t(this._coreService.triggerDataEvent(`${n.C0.ESC}${e}${n.C0.ESC}\\`),!0))('"q'===e?`P1$r${this._curAttrData.isProtected()?1:0}"q`:'"p'===e?'P1$r61;1"p':"r"===e?`P1$r${i.scrollTop+1};${i.scrollBottom+1}r`:"m"===e?"P1$r0m":" q"===e?`P1$r${{block:2,underline:4,bar:6}[s.cursorStyle]-(s.cursorBlink?1:0)} q`:"P0$r")}markRangeDirty(e,t){this._dirtyRowTracker.markRangeDirty(e,t)}}t.InputHandler=D;let L=class{constructor(e){this._bufferService=e,this.clearRange()}clearRange(){this.start=this._bufferService.buffer.y,this.end=this._bufferService.buffer.y}markDirty(e){ethis.end&&(this.end=e)}markRangeDirty(e,t){e>t&&(E=e,e=t,t=E),ethis.end&&(this.end=t)}markAllDirty(){this.markRangeDirty(0,this._bufferService.rows-1)}};function R(e){return 0<=e&&e<256}L=s([r(0,f.IBufferService)],L)},7710:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.FourKeyMap=t.TwoKeyMap=void 0;class i{constructor(){this._data={}}set(e,t,i){this._data[e]||(this._data[e]={}),this._data[e][t]=i}get(e,t){return this._data[e]?this._data[e][t]:void 0}clear(){this._data={}}}t.TwoKeyMap=i,t.FourKeyMap=class{constructor(){this._data=new i}set(e,t,s,r,n){this._data.get(e,t)||this._data.set(e,t,new i),this._data.get(e,t).set(s,r,n)}get(e,t,i,s){return this._data.get(e,t)?.get(i,s)}clear(){this._data.clear()}}},701:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.isChromeOS=t.isLinux=t.isWindows=t.isIphone=t.isIpad=t.isMac=t.isSafari=t.isLegacyEdge=t.isFirefox=t.isNode=void 0,t.getSafariVersion=function(){if(!t.isSafari)return 0;const e=i.match(/Version\/(\d+)/);return null===e||e.length<2?0:parseInt(e[1])},t.isNode="undefined"!=typeof process&&"title"in process;const i=t.isNode?"node":navigator.userAgent,s=t.isNode?"node":navigator.platform;t.isFirefox=i.includes("Firefox"),t.isLegacyEdge=i.includes("Edge"),t.isSafari=/^((?!chrome|android).)*safari/i.test(i),t.isMac=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(s),t.isIpad="iPad"===s,t.isIphone="iPhone"===s,t.isWindows=["Windows","Win16","Win32","WinCE"].includes(s),t.isLinux=s.indexOf("Linux")>=0,t.isChromeOS=/\bCrOS\b/.test(i)},3087:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SortedList=void 0;const s=i(6168);let r=0;t.SortedList=class{constructor(e){this._getKey=e,this._array=[],this._insertedValues=[],this._flushInsertedTask=new s.IdleTaskQueue,this._isFlushingInserted=!1,this._deletedIndices=[],this._flushDeletedTask=new s.IdleTaskQueue,this._isFlushingDeleted=!1}clear(){this._array.length=0,this._insertedValues.length=0,this._flushInsertedTask.clear(),this._isFlushingInserted=!1,this._deletedIndices.length=0,this._flushDeletedTask.clear(),this._isFlushingDeleted=!1}insert(e){this._flushCleanupDeleted(),0===this._insertedValues.length&&this._flushInsertedTask.enqueue((()=>this._flushInserted())),this._insertedValues.push(e)}_flushInserted(){const e=this._insertedValues.sort(((e,t)=>this._getKey(e)-this._getKey(t)));let t=0,i=0;const s=new Array(this._array.length+this._insertedValues.length);for(let r=0;r=this._array.length||this._getKey(e[t])<=this._getKey(this._array[i])?(s[r]=e[t],t++):s[r]=this._array[i++];this._array=s,this._insertedValues.length=0}_flushCleanupInserted(){!this._isFlushingInserted&&this._insertedValues.length>0&&this._flushInsertedTask.flush()}delete(e){if(this._flushCleanupInserted(),0===this._array.length)return!1;const t=this._getKey(e);if(void 0===t)return!1;if(r=this._search(t),-1===r)return!1;if(this._getKey(this._array[r])!==t)return!1;do{if(this._array[r]===e)return 0===this._deletedIndices.length&&this._flushDeletedTask.enqueue((()=>this._flushDeleted())),this._deletedIndices.push(r),!0}while(++re-t));let t=0;const i=new Array(this._array.length-e.length);let s=0;for(let r=0;r0&&this._flushDeletedTask.flush()}*getKeyIterator(e){if(this._flushCleanupInserted(),this._flushCleanupDeleted(),0!==this._array.length&&(r=this._search(e),!(r<0||r>=this._array.length)&&this._getKey(this._array[r])===e))do{yield this._array[r]}while(++r=this._array.length)&&this._getKey(this._array[r])===e))do{t(this._array[r])}while(++r=t;){let s=t+i>>1;const r=this._getKey(this._array[s]);if(r>e)i=s-1;else{if(!(r0&&this._getKey(this._array[s-1])===e;)s--;return s}t=s+1}}return t}}},6168:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DebouncedIdleTask=t.IdleTaskQueue=t.PriorityTaskQueue=void 0;const s=i(701);class r{constructor(){this._tasks=[],this._i=0}enqueue(e){this._tasks.push(e),this._start()}flush(){for(;this._ir)return s-t<-20&&console.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(s-t))}ms`),void this._start();s=r}this.clear()}}class n extends r{_requestCallback(e){return setTimeout((()=>e(this._createDeadline(16))))}_cancelCallback(e){clearTimeout(e)}_createDeadline(e){const t=performance.now()+e;return{timeRemaining:()=>Math.max(0,t-performance.now())}}}t.PriorityTaskQueue=n,t.IdleTaskQueue=!s.isNode&&"requestIdleCallback"in window?class extends r{_requestCallback(e){return requestIdleCallback(e)}_cancelCallback(e){cancelIdleCallback(e)}}:n,t.DebouncedIdleTask=class{constructor(){this._queue=new t.IdleTaskQueue}set(e){this._queue.clear(),this._queue.enqueue(e)}flush(){this._queue.flush()}}},5882:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.updateWindowsModeWrappedState=function(e){const t=e.buffer.lines.get(e.buffer.ybase+e.buffer.y-1),i=t?.get(e.cols-1),r=e.buffer.lines.get(e.buffer.ybase+e.buffer.y);r&&i&&(r.isWrapped=i[s.CHAR_DATA_CODE_INDEX]!==s.NULL_CELL_CODE&&i[s.CHAR_DATA_CODE_INDEX]!==s.WHITESPACE_CELL_CODE)};const s=i(8938)},5451:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ExtendedAttrs=t.AttributeData=void 0;class i{constructor(){this.fg=0,this.bg=0,this.extended=new s}static toColorRGB(e){return[e>>>16&255,e>>>8&255,255&e]}static fromColorRGB(e){return(255&e[0])<<16|(255&e[1])<<8|255&e[2]}clone(){const e=new i;return e.fg=this.fg,e.bg=this.bg,e.extended=this.extended.clone(),e}isInverse(){return 67108864&this.fg}isBold(){return 134217728&this.fg}isUnderline(){return this.hasExtendedAttrs()&&0!==this.extended.underlineStyle?1:268435456&this.fg}isBlink(){return 536870912&this.fg}isInvisible(){return 1073741824&this.fg}isItalic(){return 67108864&this.bg}isDim(){return 134217728&this.bg}isStrikethrough(){return 2147483648&this.fg}isProtected(){return 536870912&this.bg}isOverline(){return 1073741824&this.bg}getFgColorMode(){return 50331648&this.fg}getBgColorMode(){return 50331648&this.bg}isFgRGB(){return!(50331648&~this.fg)}isBgRGB(){return!(50331648&~this.bg)}isFgPalette(){return 16777216==(50331648&this.fg)||33554432==(50331648&this.fg)}isBgPalette(){return 16777216==(50331648&this.bg)||33554432==(50331648&this.bg)}isFgDefault(){return!(50331648&this.fg)}isBgDefault(){return!(50331648&this.bg)}isAttributeDefault(){return 0===this.fg&&0===this.bg}getFgColor(){switch(50331648&this.fg){case 16777216:case 33554432:return 255&this.fg;case 50331648:return 16777215&this.fg;default:return-1}}getBgColor(){switch(50331648&this.bg){case 16777216:case 33554432:return 255&this.bg;case 50331648:return 16777215&this.bg;default:return-1}}hasExtendedAttrs(){return 268435456&this.bg}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(268435456&this.bg&&~this.extended.underlineColor)switch(50331648&this.extended.underlineColor){case 16777216:case 33554432:return 255&this.extended.underlineColor;case 50331648:return 16777215&this.extended.underlineColor;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return 268435456&this.bg&&~this.extended.underlineColor?50331648&this.extended.underlineColor:this.getFgColorMode()}isUnderlineColorRGB(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&~this.extended.underlineColor):this.isFgRGB()}isUnderlineColorPalette(){return 268435456&this.bg&&~this.extended.underlineColor?16777216==(50331648&this.extended.underlineColor)||33554432==(50331648&this.extended.underlineColor):this.isFgPalette()}isUnderlineColorDefault(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&this.extended.underlineColor):this.isFgDefault()}getUnderlineStyle(){return 268435456&this.fg?268435456&this.bg?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}}t.AttributeData=i;class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(e){this._ext&=-67108864,this._ext|=67108863&e}get urlId(){return this._urlId}set urlId(e){this._urlId=e}get underlineVariantOffset(){const e=(3758096384&this._ext)>>29;return e<0?4294967288^e:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}constructor(e=0,t=0){this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}clone(){return new s(this._ext,this._urlId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId}}t.ExtendedAttrs=s},1073:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Buffer=t.MAX_BUFFER_SIZE=void 0;const s=i(5639),r=i(6168),n=i(5451),o=i(6107),a=i(732),l=i(3055),h=i(8938),c=i(8158),d=i(6760);t.MAX_BUFFER_SIZE=4294967295,t.Buffer=class{constructor(e,t,i){this._hasScrollback=e,this._optionsService=t,this._bufferService=i,this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.tabs={},this.savedY=0,this.savedX=0,this.savedCurAttrData=o.DEFAULT_ATTR_DATA.clone(),this.savedCharset=d.DEFAULT_CHARSET,this.markers=[],this._nullCell=l.CellData.fromCharData([0,h.NULL_CELL_CHAR,h.NULL_CELL_WIDTH,h.NULL_CELL_CODE]),this._whitespaceCell=l.CellData.fromCharData([0,h.WHITESPACE_CELL_CHAR,h.WHITESPACE_CELL_WIDTH,h.WHITESPACE_CELL_CODE]),this._isClearing=!1,this._memoryCleanupQueue=new r.IdleTaskQueue,this._memoryCleanupPosition=0,this._cols=this._bufferService.cols,this._rows=this._bufferService.rows,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}getNullCell(e){return e?(this._nullCell.fg=e.fg,this._nullCell.bg=e.bg,this._nullCell.extended=e.extended):(this._nullCell.fg=0,this._nullCell.bg=0,this._nullCell.extended=new n.ExtendedAttrs),this._nullCell}getWhitespaceCell(e){return e?(this._whitespaceCell.fg=e.fg,this._whitespaceCell.bg=e.bg,this._whitespaceCell.extended=e.extended):(this._whitespaceCell.fg=0,this._whitespaceCell.bg=0,this._whitespaceCell.extended=new n.ExtendedAttrs),this._whitespaceCell}getBlankLine(e,t){return new o.BufferLine(this._bufferService.cols,this.getNullCell(e),t)}get hasScrollback(){return this._hasScrollback&&this.lines.maxLength>this._rows}get isCursorInViewport(){const e=this.ybase+this.y-this.ydisp;return e>=0&&et.MAX_BUFFER_SIZE?t.MAX_BUFFER_SIZE:i}fillViewportRows(e){if(0===this.lines.length){void 0===e&&(e=o.DEFAULT_ATTR_DATA);let t=this._rows;for(;t--;)this.lines.push(this.getBlankLine(e))}}clear(){this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}resize(e,t){const i=this.getNullCell(o.DEFAULT_ATTR_DATA);let s=0;const r=this._getCorrectBufferLength(t);if(r>this.lines.maxLength&&(this.lines.maxLength=r),this.lines.length>0){if(this._cols0&&this.lines.length<=this.ybase+this.y+n+1?(this.ybase--,n++,this.ydisp>0&&this.ydisp--):this.lines.push(new o.BufferLine(e,i)));else for(let e=this._rows;e>t;e--)this.lines.length>t+this.ybase&&(this.lines.length>this.ybase+this.y+1?this.lines.pop():(this.ybase++,this.ydisp++));if(r0&&(this.lines.trimStart(e),this.ybase=Math.max(this.ybase-e,0),this.ydisp=Math.max(this.ydisp-e,0),this.savedY=Math.max(this.savedY-e,0)),this.lines.maxLength=r}this.x=Math.min(this.x,e-1),this.y=Math.min(this.y,t-1),n&&(this.y+=n),this.savedX=Math.min(this.savedX,e-1),this.scrollTop=0}if(this.scrollBottom=t-1,this._isReflowEnabled&&(this._reflow(e,t),this._cols>e))for(let t=0;t.1*this.lines.length&&(this._memoryCleanupPosition=0,this._memoryCleanupQueue.enqueue((()=>this._batchedMemoryCleanup())))}_batchedMemoryCleanup(){let e=!0;this._memoryCleanupPosition>=this.lines.length&&(this._memoryCleanupPosition=0,e=!1);let t=0;for(;this._memoryCleanupPosition100)return!0;return e}get _isReflowEnabled(){const e=this._optionsService.rawOptions.windowsPty;return e&&e.buildNumber?this._hasScrollback&&"conpty"===e.backend&&e.buildNumber>=21376:this._hasScrollback&&!this._optionsService.rawOptions.windowsMode}_reflow(e,t){this._cols!==e&&(e>this._cols?this._reflowLarger(e,t):this._reflowSmaller(e,t))}_reflowLarger(e,t){const i=this._optionsService.rawOptions.reflowCursorLine,s=(0,a.reflowLargerGetLinesToRemove)(this.lines,this._cols,e,this.ybase+this.y,this.getNullCell(o.DEFAULT_ATTR_DATA),i);if(s.length>0){const i=(0,a.reflowLargerCreateNewLayout)(this.lines,s);(0,a.reflowLargerApplyNewLayout)(this.lines,i.layout),this._reflowLargerAdjustViewport(e,t,i.countRemoved)}}_reflowLargerAdjustViewport(e,t,i){const s=this.getNullCell(o.DEFAULT_ATTR_DATA);let r=i;for(;r-- >0;)0===this.ybase?(this.y>0&&this.y--,this.lines.length=0;l--){let h=this.lines.get(l);if(!h||!h.isWrapped&&h.getTrimmedLength()<=e)continue;const c=[h];for(;h.isWrapped&&l>0;)h=this.lines.get(--l),c.unshift(h);if(!i){const e=this.ybase+this.y;if(e>=l&&e0&&(r.push({start:l+c.length+n,newLines:p}),n+=p.length),c.push(...p);let g=u.length-1,m=u[g];0===m&&(g--,m=u[g]);let v=c.length-_-1,S=d;for(;v>=0;){const e=Math.min(S,m);if(void 0===c[g])break;if(c[g].copyCellsFrom(c[v],S-e,m-e,e,!0),m-=e,0===m&&(g--,m=u[g]),S-=e,0===S){v--;const e=Math.max(v,0);S=(0,a.getWrappedLineTrimmedLength)(c,e,this._cols)}}for(let t=0;t0;)0===this.ybase?this.y0){const e=[],t=[];for(let e=0;e=0;h--)if(a&&a.start>s+l){for(let e=a.newLines.length-1;e>=0;e--)this.lines.set(h--,a.newLines[e]);h++,e.push({index:s+1,amount:a.newLines.length}),l+=a.newLines.length,a=r[++o]}else this.lines.set(h,t[s--]);let h=0;for(let t=e.length-1;t>=0;t--)e[t].index+=h,this.lines.onInsertEmitter.fire(e[t]),h+=e[t].amount;const c=Math.max(0,i+n-this.lines.maxLength);c>0&&this.lines.onTrimEmitter.fire(c)}}translateBufferLineToString(e,t,i=0,s){const r=this.lines.get(e);return r?r.translateToString(t,i,s):""}getWrappedRangeForLine(e){let t=e,i=e;for(;t>0&&this.lines.get(t).isWrapped;)t--;for(;i+10;);return e>=this._cols?this._cols-1:e<0?0:e}nextStop(e){for(null==e&&(e=this.x);!this.tabs[++e]&&e=this._cols?this._cols-1:e<0?0:e}clearMarkers(e){this._isClearing=!0;for(let t=0;t{t.line-=e,t.line<0&&t.dispose()}))),t.register(this.lines.onInsert((e=>{t.line>=e.index&&(t.line+=e.amount)}))),t.register(this.lines.onDelete((e=>{t.line>=e.index&&t.linee.index&&(t.line-=e.amount)}))),t.register(t.onDispose((()=>this._removeMarker(t)))),t}_removeMarker(e){this._isClearing||this.markers.splice(this.markers.indexOf(e),1)}}},6107:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLine=t.DEFAULT_ATTR_DATA=void 0;const s=i(5451),r=i(3055),n=i(8938),o=i(726);t.DEFAULT_ATTR_DATA=Object.freeze(new s.AttributeData);let a=0;class l{constructor(e,t,i=!1){this.isWrapped=i,this._combined={},this._extendedAttrs={},this._data=new Uint32Array(3*e);const s=t||r.CellData.fromCharData([0,n.NULL_CELL_CHAR,n.NULL_CELL_WIDTH,n.NULL_CELL_CODE]);for(let t=0;t>22,2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):i]}set(e,t){this._data[3*e+1]=t[n.CHAR_DATA_ATTR_INDEX],t[n.CHAR_DATA_CHAR_INDEX].length>1?(this._combined[e]=t[1],this._data[3*e+0]=2097152|e|t[n.CHAR_DATA_WIDTH_INDEX]<<22):this._data[3*e+0]=t[n.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|t[n.CHAR_DATA_WIDTH_INDEX]<<22}getWidth(e){return this._data[3*e+0]>>22}hasWidth(e){return 12582912&this._data[3*e+0]}getFg(e){return this._data[3*e+1]}getBg(e){return this._data[3*e+2]}hasContent(e){return 4194303&this._data[3*e+0]}getCodePoint(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):2097151&t}isCombined(e){return 2097152&this._data[3*e+0]}getString(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e]:2097151&t?(0,o.stringFromCodePoint)(2097151&t):""}isProtected(e){return 536870912&this._data[3*e+2]}loadCell(e,t){return a=3*e,t.content=this._data[a+0],t.fg=this._data[a+1],t.bg=this._data[a+2],2097152&t.content&&(t.combinedData=this._combined[e]),268435456&t.bg&&(t.extended=this._extendedAttrs[e]),t}setCell(e,t){2097152&t.content&&(this._combined[e]=t.combinedData),268435456&t.bg&&(this._extendedAttrs[e]=t.extended),this._data[3*e+0]=t.content,this._data[3*e+1]=t.fg,this._data[3*e+2]=t.bg}setCellFromCodepoint(e,t,i,s){268435456&s.bg&&(this._extendedAttrs[e]=s.extended),this._data[3*e+0]=t|i<<22,this._data[3*e+1]=s.fg,this._data[3*e+2]=s.bg}addCodepointToCell(e,t,i){let s=this._data[3*e+0];2097152&s?this._combined[e]+=(0,o.stringFromCodePoint)(t):2097151&s?(this._combined[e]=(0,o.stringFromCodePoint)(2097151&s)+(0,o.stringFromCodePoint)(t),s&=-2097152,s|=2097152):s=t|1<<22,i&&(s&=-12582913,s|=i<<22),this._data[3*e+0]=s}insertCells(e,t,i){if((e%=this.length)&&2===this.getWidth(e-1)&&this.setCellFromCodepoint(e-1,0,1,i),t=0;--i)this.setCell(e+t+i,this.loadCell(e+i,s));for(let s=0;sthis.length){if(this._data.buffer.byteLength>=4*i)this._data=new Uint32Array(this._data.buffer,0,i);else{const e=new Uint32Array(i);e.set(this._data),this._data=e}for(let i=this.length;i=e&&delete this._combined[s]}const s=Object.keys(this._extendedAttrs);for(let t=0;t=e&&delete this._extendedAttrs[i]}}return this.length=e,4*i*2=0;--e)if(4194303&this._data[3*e+0])return e+(this._data[3*e+0]>>22);return 0}getNoBgTrimmedLength(){for(let e=this.length-1;e>=0;--e)if(4194303&this._data[3*e+0]||50331648&this._data[3*e+2])return e+(this._data[3*e+0]>>22);return 0}copyCellsFrom(e,t,i,s,r){const n=e._data;if(r)for(let r=s-1;r>=0;r--){for(let e=0;e<3;e++)this._data[3*(i+r)+e]=n[3*(t+r)+e];268435456&n[3*(t+r)+2]&&(this._extendedAttrs[i+r]=e._extendedAttrs[t+r])}else for(let r=0;r=t&&(this._combined[r-t+i]=e._combined[r])}}translateToString(e,t,i,s){t=t??0,i=i??this.length,e&&(i=Math.min(i,this.getTrimmedLength())),s&&(s.length=0);let r="";for(;t>22||1}return s&&s.push(t),r}}t.BufferLine=l},9384:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.getRangeLength=function(e,t){if(e.start.y>e.end.y)throw new Error(`Buffer range end (${e.end.x}, ${e.end.y}) cannot be before start (${e.start.x}, ${e.start.y})`);return t*(e.end.y-e.start.y)+(e.end.x-e.start.x+1)}},732:(e,t)=>{function i(e,t,i){if(t===e.length-1)return e[t].getTrimmedLength();const s=!e[t].hasContent(i-1)&&1===e[t].getWidth(i-1),r=2===e[t+1].getWidth(0);return s&&r?i-1:i}Object.defineProperty(t,"__esModule",{value:!0}),t.reflowLargerGetLinesToRemove=function(e,t,s,r,n,o){const a=[];for(let l=0;l=l&&r0&&(e>u||0===d[e].getTrimmedLength());e--)g++;g>0&&(a.push(l+d.length-g),a.push(g)),l+=d.length-1}return a},t.reflowLargerCreateNewLayout=function(e,t){const i=[];let s=0,r=t[s],n=0;for(let o=0;oi(e,r,t))).reduce(((e,t)=>e+t));let o=0,a=0,l=0;for(;lh&&(o-=h,a++);const c=2===e[a].getWidth(o-1);c&&o--;const d=c?s-1:s;r.push(d),l+=d}return r},t.getWrappedLineTrimmedLength=i},4097:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferSet=void 0;const s=i(7150),r=i(1073),n=i(802);class o extends s.Disposable{constructor(e,t){super(),this._optionsService=e,this._bufferService=t,this._onBufferActivate=this._register(new n.Emitter),this.onBufferActivate=this._onBufferActivate.event,this.reset(),this._register(this._optionsService.onSpecificOptionChange("scrollback",(()=>this.resize(this._bufferService.cols,this._bufferService.rows)))),this._register(this._optionsService.onSpecificOptionChange("tabStopWidth",(()=>this.setupTabStops())))}reset(){this._normal=new r.Buffer(!0,this._optionsService,this._bufferService),this._normal.fillViewportRows(),this._alt=new r.Buffer(!1,this._optionsService,this._bufferService),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}),this.setupTabStops()}get alt(){return this._alt}get active(){return this._activeBuffer}get normal(){return this._normal}activateNormalBuffer(){this._activeBuffer!==this._normal&&(this._normal.x=this._alt.x,this._normal.y=this._alt.y,this._alt.clearAllMarkers(),this._alt.clear(),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}))}activateAltBuffer(e){this._activeBuffer!==this._alt&&(this._alt.fillViewportRows(e),this._alt.x=this._normal.x,this._alt.y=this._normal.y,this._activeBuffer=this._alt,this._onBufferActivate.fire({activeBuffer:this._alt,inactiveBuffer:this._normal}))}resize(e,t){this._normal.resize(e,t),this._alt.resize(e,t),this.setupTabStops(e)}setupTabStops(e){this._normal.setupTabStops(e),this._alt.setupTabStops(e)}}t.BufferSet=o},3055:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CellData=void 0;const s=i(726),r=i(8938),n=i(5451);class o extends n.AttributeData{constructor(){super(...arguments),this.content=0,this.fg=0,this.bg=0,this.extended=new n.ExtendedAttrs,this.combinedData=""}static fromCharData(e){const t=new o;return t.setFromCharData(e),t}isCombined(){return 2097152&this.content}getWidth(){return this.content>>22}getChars(){return 2097152&this.content?this.combinedData:2097151&this.content?(0,s.stringFromCodePoint)(2097151&this.content):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):2097151&this.content}setFromCharData(e){this.fg=e[r.CHAR_DATA_ATTR_INDEX],this.bg=0;let t=!1;if(e[r.CHAR_DATA_CHAR_INDEX].length>2)t=!0;else if(2===e[r.CHAR_DATA_CHAR_INDEX].length){const i=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0);if(55296<=i&&i<=56319){const s=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(1);56320<=s&&s<=57343?this.content=1024*(i-55296)+s-56320+65536|e[r.CHAR_DATA_WIDTH_INDEX]<<22:t=!0}else t=!0}else this.content=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|e[r.CHAR_DATA_WIDTH_INDEX]<<22;t&&(this.combinedData=e[r.CHAR_DATA_CHAR_INDEX],this.content=2097152|e[r.CHAR_DATA_WIDTH_INDEX]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.CellData=o},8938:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.WHITESPACE_CELL_CODE=t.WHITESPACE_CELL_WIDTH=t.WHITESPACE_CELL_CHAR=t.NULL_CELL_CODE=t.NULL_CELL_WIDTH=t.NULL_CELL_CHAR=t.CHAR_DATA_CODE_INDEX=t.CHAR_DATA_WIDTH_INDEX=t.CHAR_DATA_CHAR_INDEX=t.CHAR_DATA_ATTR_INDEX=t.DEFAULT_EXT=t.DEFAULT_ATTR=t.DEFAULT_COLOR=void 0,t.DEFAULT_COLOR=0,t.DEFAULT_ATTR=t.DEFAULT_COLOR<<9|256,t.DEFAULT_EXT=0,t.CHAR_DATA_ATTR_INDEX=0,t.CHAR_DATA_CHAR_INDEX=1,t.CHAR_DATA_WIDTH_INDEX=2,t.CHAR_DATA_CODE_INDEX=3,t.NULL_CELL_CHAR="",t.NULL_CELL_WIDTH=1,t.NULL_CELL_CODE=0,t.WHITESPACE_CELL_CHAR=" ",t.WHITESPACE_CELL_WIDTH=1,t.WHITESPACE_CELL_CODE=32},8158:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Marker=void 0;const s=i(802),r=i(7150);class n{get id(){return this._id}constructor(e){this.line=e,this.isDisposed=!1,this._disposables=[],this._id=n._nextId++,this._onDispose=this.register(new s.Emitter),this.onDispose=this._onDispose.event}dispose(){this.isDisposed||(this.isDisposed=!0,this.line=-1,this._onDispose.fire(),(0,r.dispose)(this._disposables),this._disposables.length=0)}register(e){return this._disposables.push(e),e}}t.Marker=n,n._nextId=1},6760:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_CHARSET=t.CHARSETS=void 0,t.CHARSETS={},t.DEFAULT_CHARSET=t.CHARSETS.B,t.CHARSETS[0]={"`":"◆",a:"▒",b:"␉",c:"␌",d:"␍",e:"␊",f:"°",g:"±",h:"␤",i:"␋",j:"┘",k:"┐",l:"┌",m:"└",n:"┼",o:"⎺",p:"⎻",q:"─",r:"⎼",s:"⎽",t:"├",u:"┤",v:"┴",w:"┬",x:"│",y:"≤",z:"≥","{":"π","|":"≠","}":"£","~":"·"},t.CHARSETS.A={"#":"£"},t.CHARSETS.B=void 0,t.CHARSETS[4]={"#":"£","@":"¾","[":"ij","\\":"½","]":"|","{":"¨","|":"f","}":"¼","~":"´"},t.CHARSETS.C=t.CHARSETS[5]={"[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS.R={"#":"£","@":"à","[":"°","\\":"ç","]":"§","{":"é","|":"ù","}":"è","~":"¨"},t.CHARSETS.Q={"@":"à","[":"â","\\":"ç","]":"ê","^":"î","`":"ô","{":"é","|":"ù","}":"è","~":"û"},t.CHARSETS.K={"@":"§","[":"Ä","\\":"Ö","]":"Ü","{":"ä","|":"ö","}":"ü","~":"ß"},t.CHARSETS.Y={"#":"£","@":"§","[":"°","\\":"ç","]":"é","`":"ù","{":"à","|":"ò","}":"è","~":"ì"},t.CHARSETS.E=t.CHARSETS[6]={"@":"Ä","[":"Æ","\\":"Ø","]":"Å","^":"Ü","`":"ä","{":"æ","|":"ø","}":"å","~":"ü"},t.CHARSETS.Z={"#":"£","@":"§","[":"¡","\\":"Ñ","]":"¿","{":"°","|":"ñ","}":"ç"},t.CHARSETS.H=t.CHARSETS[7]={"@":"É","[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS["="]={"#":"ù","@":"à","[":"é","\\":"ç","]":"ê","^":"î",_:"è","`":"ô","{":"ä","|":"ö","}":"ü","~":"û"}},3534:(e,t)=>{var i,s,r;Object.defineProperty(t,"__esModule",{value:!0}),t.C1_ESCAPED=t.C1=t.C0=void 0,function(e){e.NUL="\0",e.SOH="",e.STX="",e.ETX="",e.EOT="",e.ENQ="",e.ACK="",e.BEL="",e.BS="\b",e.HT="\t",e.LF="\n",e.VT="\v",e.FF="\f",e.CR="\r",e.SO="",e.SI="",e.DLE="",e.DC1="",e.DC2="",e.DC3="",e.DC4="",e.NAK="",e.SYN="",e.ETB="",e.CAN="",e.EM="",e.SUB="",e.ESC="",e.FS="",e.GS="",e.RS="",e.US="",e.SP=" ",e.DEL=""}(i||(t.C0=i={})),function(e){e.PAD="€",e.HOP="",e.BPH="‚",e.NBH="ƒ",e.IND="„",e.NEL="…",e.SSA="†",e.ESA="‡",e.HTS="ˆ",e.HTJ="‰",e.VTS="Š",e.PLD="‹",e.PLU="Œ",e.RI="",e.SS2="Ž",e.SS3="",e.DCS="",e.PU1="‘",e.PU2="’",e.STS="“",e.CCH="”",e.MW="•",e.SPA="–",e.EPA="—",e.SOS="˜",e.SGCI="™",e.SCI="š",e.CSI="›",e.ST="œ",e.OSC="",e.PM="ž",e.APC="Ÿ"}(s||(t.C1=s={})),function(e){e.ST=`${i.ESC}\\`}(r||(t.C1_ESCAPED=r={}))},706:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.evaluateKeyboardEvent=function(e,t,i,n){const o={type:0,cancel:!1,key:void 0},a=(e.shiftKey?1:0)|(e.altKey?2:0)|(e.ctrlKey?4:0)|(e.metaKey?8:0);switch(e.keyCode){case 0:"UIKeyInputUpArrow"===e.key?o.key=t?s.C0.ESC+"OA":s.C0.ESC+"[A":"UIKeyInputLeftArrow"===e.key?o.key=t?s.C0.ESC+"OD":s.C0.ESC+"[D":"UIKeyInputRightArrow"===e.key?o.key=t?s.C0.ESC+"OC":s.C0.ESC+"[C":"UIKeyInputDownArrow"===e.key&&(o.key=t?s.C0.ESC+"OB":s.C0.ESC+"[B");break;case 8:o.key=e.ctrlKey?"\b":s.C0.DEL,e.altKey&&(o.key=s.C0.ESC+o.key);break;case 9:if(e.shiftKey){o.key=s.C0.ESC+"[Z";break}o.key=s.C0.HT,o.cancel=!0;break;case 13:o.key=e.altKey?s.C0.ESC+s.C0.CR:s.C0.CR,o.cancel=!0;break;case 27:o.key=s.C0.ESC,e.altKey&&(o.key=s.C0.ESC+s.C0.ESC),o.cancel=!0;break;case 37:if(e.metaKey)break;o.key=a?s.C0.ESC+"[1;"+(a+1)+"D":t?s.C0.ESC+"OD":s.C0.ESC+"[D";break;case 39:if(e.metaKey)break;o.key=a?s.C0.ESC+"[1;"+(a+1)+"C":t?s.C0.ESC+"OC":s.C0.ESC+"[C";break;case 38:if(e.metaKey)break;o.key=a?s.C0.ESC+"[1;"+(a+1)+"A":t?s.C0.ESC+"OA":s.C0.ESC+"[A";break;case 40:if(e.metaKey)break;o.key=a?s.C0.ESC+"[1;"+(a+1)+"B":t?s.C0.ESC+"OB":s.C0.ESC+"[B";break;case 45:e.shiftKey||e.ctrlKey||(o.key=s.C0.ESC+"[2~");break;case 46:o.key=a?s.C0.ESC+"[3;"+(a+1)+"~":s.C0.ESC+"[3~";break;case 36:o.key=a?s.C0.ESC+"[1;"+(a+1)+"H":t?s.C0.ESC+"OH":s.C0.ESC+"[H";break;case 35:o.key=a?s.C0.ESC+"[1;"+(a+1)+"F":t?s.C0.ESC+"OF":s.C0.ESC+"[F";break;case 33:e.shiftKey?o.type=2:e.ctrlKey?o.key=s.C0.ESC+"[5;"+(a+1)+"~":o.key=s.C0.ESC+"[5~";break;case 34:e.shiftKey?o.type=3:e.ctrlKey?o.key=s.C0.ESC+"[6;"+(a+1)+"~":o.key=s.C0.ESC+"[6~";break;case 112:o.key=a?s.C0.ESC+"[1;"+(a+1)+"P":s.C0.ESC+"OP";break;case 113:o.key=a?s.C0.ESC+"[1;"+(a+1)+"Q":s.C0.ESC+"OQ";break;case 114:o.key=a?s.C0.ESC+"[1;"+(a+1)+"R":s.C0.ESC+"OR";break;case 115:o.key=a?s.C0.ESC+"[1;"+(a+1)+"S":s.C0.ESC+"OS";break;case 116:o.key=a?s.C0.ESC+"[15;"+(a+1)+"~":s.C0.ESC+"[15~";break;case 117:o.key=a?s.C0.ESC+"[17;"+(a+1)+"~":s.C0.ESC+"[17~";break;case 118:o.key=a?s.C0.ESC+"[18;"+(a+1)+"~":s.C0.ESC+"[18~";break;case 119:o.key=a?s.C0.ESC+"[19;"+(a+1)+"~":s.C0.ESC+"[19~";break;case 120:o.key=a?s.C0.ESC+"[20;"+(a+1)+"~":s.C0.ESC+"[20~";break;case 121:o.key=a?s.C0.ESC+"[21;"+(a+1)+"~":s.C0.ESC+"[21~";break;case 122:o.key=a?s.C0.ESC+"[23;"+(a+1)+"~":s.C0.ESC+"[23~";break;case 123:o.key=a?s.C0.ESC+"[24;"+(a+1)+"~":s.C0.ESC+"[24~";break;default:if(!e.ctrlKey||e.shiftKey||e.altKey||e.metaKey)if(i&&!n||!e.altKey||e.metaKey)!i||e.altKey||e.ctrlKey||e.shiftKey||!e.metaKey?e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.keyCode>=48&&1===e.key.length?o.key=e.key:e.key&&e.ctrlKey&&("_"===e.key&&(o.key=s.C0.US),"@"===e.key&&(o.key=s.C0.NUL)):65===e.keyCode&&(o.type=1);else{const t=r[e.keyCode],i=t?.[e.shiftKey?1:0];if(i)o.key=s.C0.ESC+i;else if(e.keyCode>=65&&e.keyCode<=90){const t=e.ctrlKey?e.keyCode-64:e.keyCode+32;let i=String.fromCharCode(t);e.shiftKey&&(i=i.toUpperCase()),o.key=s.C0.ESC+i}else if(32===e.keyCode)o.key=s.C0.ESC+(e.ctrlKey?s.C0.NUL:" ");else if("Dead"===e.key&&e.code.startsWith("Key")){let t=e.code.slice(3,4);e.shiftKey||(t=t.toLowerCase()),o.key=s.C0.ESC+t,o.cancel=!0}}else e.keyCode>=65&&e.keyCode<=90?o.key=String.fromCharCode(e.keyCode-64):32===e.keyCode?o.key=s.C0.NUL:e.keyCode>=51&&e.keyCode<=55?o.key=String.fromCharCode(e.keyCode-51+27):56===e.keyCode?o.key=s.C0.DEL:219===e.keyCode?o.key=s.C0.ESC:220===e.keyCode?o.key=s.C0.FS:221===e.keyCode&&(o.key=s.C0.GS)}return o};const s=i(3534),r={48:["0",")"],49:["1","!"],50:["2","@"],51:["3","#"],52:["4","$"],53:["5","%"],54:["6","^"],55:["7","&"],56:["8","*"],57:["9","("],186:[";",":"],187:["=","+"],188:[",","<"],189:["-","_"],190:[".",">"],191:["/","?"],192:["`","~"],219:["[","{"],220:["\\","|"],221:["]","}"],222:["'",'"']}},726:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Utf8ToUtf32=t.StringToUtf32=void 0,t.stringFromCodePoint=function(e){return e>65535?(e-=65536,String.fromCharCode(55296+(e>>10))+String.fromCharCode(e%1024+56320)):String.fromCharCode(e)},t.utf32ToString=function(e,t=0,i=e.length){let s="";for(let r=t;r65535?(t-=65536,s+=String.fromCharCode(55296+(t>>10))+String.fromCharCode(t%1024+56320)):s+=String.fromCharCode(t)}return s},t.StringToUtf32=class{constructor(){this._interim=0}clear(){this._interim=0}decode(e,t){const i=e.length;if(!i)return 0;let s=0,r=0;if(this._interim){const i=e.charCodeAt(r++);56320<=i&&i<=57343?t[s++]=1024*(this._interim-55296)+i-56320+65536:(t[s++]=this._interim,t[s++]=i),this._interim=0}for(let n=r;n=i)return this._interim=r,s;const o=e.charCodeAt(n);56320<=o&&o<=57343?t[s++]=1024*(r-55296)+o-56320+65536:(t[s++]=r,t[s++]=o)}else 65279!==r&&(t[s++]=r)}return s}},t.Utf8ToUtf32=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(e,t){const i=e.length;if(!i)return 0;let s,r,n,o,a=0,l=0,h=0;if(this.interim[0]){let s=!1,r=this.interim[0];r&=192==(224&r)?31:224==(240&r)?15:7;let n,o=0;for(;(n=63&this.interim[++o])&&o<4;)r<<=6,r|=n;const l=192==(224&this.interim[0])?2:224==(240&this.interim[0])?3:4,c=l-o;for(;h=i)return 0;if(n=e[h++],128!=(192&n)){h--,s=!0;break}this.interim[o++]=n,r<<=6,r|=63&n}s||(2===l?r<128?h--:t[a++]=r:3===l?r<2048||r>=55296&&r<=57343||65279===r||(t[a++]=r):r<65536||r>1114111||(t[a++]=r)),this.interim.fill(0)}const c=i-4;let d=h;for(;d=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(l=(31&s)<<6|63&r,l<128){d--;continue}t[a++]=l}else if(224==(240&s)){if(d>=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,a;if(n=e[d++],128!=(192&n)){d--;continue}if(l=(15&s)<<12|(63&r)<<6|63&n,l<2048||l>=55296&&l<=57343||65279===l)continue;t[a++]=l}else if(240==(248&s)){if(d>=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,a;if(n=e[d++],128!=(192&n)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,this.interim[2]=n,a;if(o=e[d++],128!=(192&o)){d--;continue}if(l=(7&s)<<18|(63&r)<<12|(63&n)<<6|63&o,l<65536||l>1114111)continue;t[a++]=l}}return a}}},7428:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeV6=void 0;const s=i(6415),r=[[768,879],[1155,1158],[1160,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1539],[1552,1557],[1611,1630],[1648,1648],[1750,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2305,2306],[2364,2364],[2369,2376],[2381,2381],[2385,2388],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2672,2673],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2817,2817],[2876,2876],[2879,2879],[2881,2883],[2893,2893],[2902,2902],[2946,2946],[3008,3008],[3021,3021],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3393,3395],[3405,3405],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3769],[3771,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3984,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4146],[4150,4151],[4153,4153],[4184,4185],[4448,4607],[4959,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6157],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7616,7626],[7678,7679],[8203,8207],[8234,8238],[8288,8291],[8298,8303],[8400,8431],[12330,12335],[12441,12442],[43014,43014],[43019,43019],[43045,43046],[64286,64286],[65024,65039],[65056,65059],[65279,65279],[65529,65531]],n=[[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[917505,917505],[917536,917631],[917760,917999]];let o;t.UnicodeV6=class{constructor(){if(this.version="6",!o){o=new Uint8Array(65536),o.fill(1),o[0]=0,o.fill(0,1,32),o.fill(0,127,160),o.fill(2,4352,4448),o[9001]=2,o[9002]=2,o.fill(2,11904,42192),o[12351]=1,o.fill(2,44032,55204),o.fill(2,63744,64256),o.fill(2,65040,65050),o.fill(2,65072,65136),o.fill(2,65280,65377),o.fill(2,65504,65511);for(let e=0;et[r][1])return!1;for(;r>=s;)if(i=s+r>>1,e>t[i][1])s=i+1;else{if(!(e=131072&&e<=196605||e>=196608&&e<=262141?2:1}charProperties(e,t){let i=this.wcwidth(e),r=0===i&&0!==t;if(r){const e=s.UnicodeService.extractWidth(t);0===e?r=!1:e>i&&(i=e)}return s.UnicodeService.createPropertyValue(0,i,r)}}},3562:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.WriteBuffer=void 0;const s=i(7150),r=i(802);class n extends s.Disposable{constructor(e){super(),this._action=e,this._writeBuffer=[],this._callbacks=[],this._pendingData=0,this._bufferOffset=0,this._isSyncWriting=!1,this._syncCalls=0,this._didUserInput=!1,this._onWriteParsed=this._register(new r.Emitter),this.onWriteParsed=this._onWriteParsed.event}handleUserInput(){this._didUserInput=!0}writeSync(e,t){if(void 0!==t&&this._syncCalls>t)return void(this._syncCalls=0);if(this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(void 0),this._syncCalls++,this._isSyncWriting)return;let i;for(this._isSyncWriting=!0;i=this._writeBuffer.shift();){this._action(i);const e=this._callbacks.shift();e&&e()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}write(e,t){if(this._pendingData>5e7)throw new Error("write data discarded, use flow control to avoid losing data");if(!this._writeBuffer.length){if(this._bufferOffset=0,this._didUserInput)return this._didUserInput=!1,this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t),void this._innerWrite();setTimeout((()=>this._innerWrite()))}this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t)}_innerWrite(e=0,t=!0){const i=e||performance.now();for(;this._writeBuffer.length>this._bufferOffset;){const e=this._writeBuffer[this._bufferOffset],s=this._action(e,t);if(s){const e=e=>performance.now()-i>=12?setTimeout((()=>this._innerWrite(0,e))):this._innerWrite(i,e);return void s.catch((e=>(queueMicrotask((()=>{throw e})),Promise.resolve(!1)))).then(e)}const r=this._callbacks[this._bufferOffset];if(r&&r(),this._bufferOffset++,this._pendingData-=e.length,performance.now()-i>=12)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>50&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),setTimeout((()=>this._innerWrite()))):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()}}t.WriteBuffer=n},8693:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.parseColor=function(e){if(!e)return;let t=e.toLowerCase();if(0===t.indexOf("rgb:")){t=t.slice(4);const e=i.exec(t);if(e){const t=e[1]?15:e[4]?255:e[7]?4095:65535;return[Math.round(parseInt(e[1]||e[4]||e[7]||e[10],16)/t*255),Math.round(parseInt(e[2]||e[5]||e[8]||e[11],16)/t*255),Math.round(parseInt(e[3]||e[6]||e[9]||e[12],16)/t*255)]}}else if(0===t.indexOf("#")&&(t=t.slice(1),s.exec(t)&&[3,6,9,12].includes(t.length))){const e=t.length/3,i=[0,0,0];for(let s=0;s<3;++s){const r=parseInt(t.slice(e*s,e*s+e),16);i[s]=1===e?r<<4:2===e?r:3===e?r>>4:r>>8}return i}},t.toRgbString=function(e,t=16){const[i,s,n]=e;return`rgb:${r(i,t)}/${r(s,t)}/${r(n,t)}`};const i=/^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/,s=/^[\da-f]+$/;function r(e,t){const i=e.toString(16),s=i.length<2?"0"+i:i;switch(t){case 4:return i[0];case 8:return s;case 12:return(s+s).slice(0,3);default:return s+s}}},1263:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.PAYLOAD_LIMIT=void 0,t.PAYLOAD_LIMIT=2e5},9823:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DcsHandler=t.DcsParser=void 0;const s=i(726),r=i(7262),n=i(1263),o=[];t.DcsParser=class{constructor(){this._handlers=Object.create(null),this._active=o,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=o}registerHandler(e,t){void 0===this._handlers[e]&&(this._handlers[e]=[]);const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}reset(){if(this._active.length)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].unhook(!1);this._stack.paused=!1,this._active=o,this._ident=0}hook(e,t){if(this.reset(),this._ident=e,this._active=this._handlers[e]||o,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].hook(t);else this._handlerFb(this._ident,"HOOK",t)}put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._ident,"PUT",(0,s.utf32ToString)(e,t,i))}unhook(e,t=!0){if(this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].unhook(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].unhook(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._ident,"UNHOOK",e);this._active=o,this._ident=0}};const a=new r.Params;a.addParam(0),t.DcsHandler=class{constructor(e){this._handler=e,this._data="",this._params=a,this._hitLimit=!1}hook(e){this._params=e.length>1||e.params[0]?e.clone():a,this._data="",this._hitLimit=!1}put(e,t,i){this._hitLimit||(this._data+=(0,s.utf32ToString)(e,t,i),this._data.length>n.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))}unhook(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data,this._params),t instanceof Promise))return t.then((e=>(this._params=a,this._data="",this._hitLimit=!1,e)));return this._params=a,this._data="",this._hitLimit=!1,t}}},6717:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.EscapeSequenceParser=t.VT500_TRANSITION_TABLE=t.TransitionTable=void 0;const s=i(7150),r=i(7262),n=i(1346),o=i(9823);class a{constructor(e){this.table=new Uint8Array(e)}setDefault(e,t){this.table.fill(e<<4|t)}add(e,t,i,s){this.table[t<<8|e]=i<<4|s}addMany(e,t,i,s){for(let r=0;rt)),i=(e,i)=>t.slice(e,i),s=i(32,127),r=i(0,24);r.push(25),r.push.apply(r,i(28,32));const n=i(0,14);let o;for(o in e.setDefault(1,0),e.addMany(s,0,2,0),n)e.addMany([24,26,153,154],o,3,0),e.addMany(i(128,144),o,3,0),e.addMany(i(144,152),o,3,0),e.add(156,o,0,0),e.add(27,o,11,1),e.add(157,o,4,8),e.addMany([152,158,159],o,0,7),e.add(155,o,11,3),e.add(144,o,11,9);return e.addMany(r,0,3,0),e.addMany(r,1,3,1),e.add(127,1,0,1),e.addMany(r,8,0,8),e.addMany(r,3,3,3),e.add(127,3,0,3),e.addMany(r,4,3,4),e.add(127,4,0,4),e.addMany(r,6,3,6),e.addMany(r,5,3,5),e.add(127,5,0,5),e.addMany(r,2,3,2),e.add(127,2,0,2),e.add(93,1,4,8),e.addMany(s,8,5,8),e.add(127,8,5,8),e.addMany([156,27,24,26,7],8,6,0),e.addMany(i(28,32),8,0,8),e.addMany([88,94,95],1,0,7),e.addMany(s,7,0,7),e.addMany(r,7,0,7),e.add(156,7,0,0),e.add(127,7,0,7),e.add(91,1,11,3),e.addMany(i(64,127),3,7,0),e.addMany(i(48,60),3,8,4),e.addMany([60,61,62,63],3,9,4),e.addMany(i(48,60),4,8,4),e.addMany(i(64,127),4,7,0),e.addMany([60,61,62,63],4,0,6),e.addMany(i(32,64),6,0,6),e.add(127,6,0,6),e.addMany(i(64,127),6,0,0),e.addMany(i(32,48),3,9,5),e.addMany(i(32,48),5,9,5),e.addMany(i(48,64),5,0,6),e.addMany(i(64,127),5,7,0),e.addMany(i(32,48),4,9,5),e.addMany(i(32,48),1,9,2),e.addMany(i(32,48),2,9,2),e.addMany(i(48,127),2,10,0),e.addMany(i(48,80),1,10,0),e.addMany(i(81,88),1,10,0),e.addMany([89,90,92],1,10,0),e.addMany(i(96,127),1,10,0),e.add(80,1,11,9),e.addMany(r,9,0,9),e.add(127,9,0,9),e.addMany(i(28,32),9,0,9),e.addMany(i(32,48),9,9,12),e.addMany(i(48,60),9,8,10),e.addMany([60,61,62,63],9,9,10),e.addMany(r,11,0,11),e.addMany(i(32,128),11,0,11),e.addMany(i(28,32),11,0,11),e.addMany(r,10,0,10),e.add(127,10,0,10),e.addMany(i(28,32),10,0,10),e.addMany(i(48,60),10,8,10),e.addMany([60,61,62,63],10,0,11),e.addMany(i(32,48),10,9,12),e.addMany(r,12,0,12),e.add(127,12,0,12),e.addMany(i(28,32),12,0,12),e.addMany(i(32,48),12,9,12),e.addMany(i(48,64),12,0,11),e.addMany(i(64,127),12,12,13),e.addMany(i(64,127),10,12,13),e.addMany(i(64,127),9,12,13),e.addMany(r,13,13,13),e.addMany(s,13,13,13),e.add(127,13,0,13),e.addMany([27,156,24,26],13,14,0),e.add(l,0,2,0),e.add(l,8,5,8),e.add(l,6,0,6),e.add(l,11,0,11),e.add(l,13,13,13),e}();class h extends s.Disposable{constructor(e=t.VT500_TRANSITION_TABLE){super(),this._transitions=e,this._parseStack={state:0,handlers:[],handlerPos:0,transition:0,chunkPos:0},this.initialState=0,this.currentState=this.initialState,this._params=new r.Params,this._params.addParam(0),this._collect=0,this.precedingJoinState=0,this._printHandlerFb=(e,t,i)=>{},this._executeHandlerFb=e=>{},this._csiHandlerFb=(e,t)=>{},this._escHandlerFb=e=>{},this._errorHandlerFb=e=>e,this._printHandler=this._printHandlerFb,this._executeHandlers=Object.create(null),this._csiHandlers=Object.create(null),this._escHandlers=Object.create(null),this._register((0,s.toDisposable)((()=>{this._csiHandlers=Object.create(null),this._executeHandlers=Object.create(null),this._escHandlers=Object.create(null)}))),this._oscParser=this._register(new n.OscParser),this._dcsParser=this._register(new o.DcsParser),this._errorHandler=this._errorHandlerFb,this.registerEscHandler({final:"\\"},(()=>!0))}_identifier(e,t=[64,126]){let i=0;if(e.prefix){if(e.prefix.length>1)throw new Error("only one byte as prefix supported");if(i=e.prefix.charCodeAt(0),i&&60>i||i>63)throw new Error("prefix must be in range 0x3c .. 0x3f")}if(e.intermediates){if(e.intermediates.length>2)throw new Error("only two bytes as intermediates are supported");for(let t=0;ts||s>47)throw new Error("intermediate must be in range 0x20 .. 0x2f");i<<=8,i|=s}}if(1!==e.final.length)throw new Error("final must be a single byte");const s=e.final.charCodeAt(0);if(t[0]>s||s>t[1])throw new Error(`final must be in range ${t[0]} .. ${t[1]}`);return i<<=8,i|=s,i}identToString(e){const t=[];for(;e;)t.push(String.fromCharCode(255&e)),e>>=8;return t.reverse().join("")}setPrintHandler(e){this._printHandler=e}clearPrintHandler(){this._printHandler=this._printHandlerFb}registerEscHandler(e,t){const i=this._identifier(e,[48,126]);void 0===this._escHandlers[i]&&(this._escHandlers[i]=[]);const s=this._escHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearEscHandler(e){this._escHandlers[this._identifier(e,[48,126])]&&delete this._escHandlers[this._identifier(e,[48,126])]}setEscHandlerFallback(e){this._escHandlerFb=e}setExecuteHandler(e,t){this._executeHandlers[e.charCodeAt(0)]=t}clearExecuteHandler(e){this._executeHandlers[e.charCodeAt(0)]&&delete this._executeHandlers[e.charCodeAt(0)]}setExecuteHandlerFallback(e){this._executeHandlerFb=e}registerCsiHandler(e,t){const i=this._identifier(e);void 0===this._csiHandlers[i]&&(this._csiHandlers[i]=[]);const s=this._csiHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearCsiHandler(e){this._csiHandlers[this._identifier(e)]&&delete this._csiHandlers[this._identifier(e)]}setCsiHandlerFallback(e){this._csiHandlerFb=e}registerDcsHandler(e,t){return this._dcsParser.registerHandler(this._identifier(e),t)}clearDcsHandler(e){this._dcsParser.clearHandler(this._identifier(e))}setDcsHandlerFallback(e){this._dcsParser.setHandlerFallback(e)}registerOscHandler(e,t){return this._oscParser.registerHandler(e,t)}clearOscHandler(e){this._oscParser.clearHandler(e)}setOscHandlerFallback(e){this._oscParser.setHandlerFallback(e)}setErrorHandler(e){this._errorHandler=e}clearErrorHandler(){this._errorHandler=this._errorHandlerFb}reset(){this.currentState=this.initialState,this._oscParser.reset(),this._dcsParser.reset(),this._params.reset(),this._params.addParam(0),this._collect=0,this.precedingJoinState=0,0!==this._parseStack.state&&(this._parseStack.state=2,this._parseStack.handlers=[])}_preserveStack(e,t,i,s,r){this._parseStack.state=e,this._parseStack.handlers=t,this._parseStack.handlerPos=i,this._parseStack.transition=s,this._parseStack.chunkPos=r}parse(e,t,i){let s,r=0,n=0,o=0;if(this._parseStack.state)if(2===this._parseStack.state)this._parseStack.state=0,o=this._parseStack.chunkPos+1;else{if(void 0===i||1===this._parseStack.state)throw this._parseStack.state=1,new Error("improper continuation due to previous async handler, giving up parsing");const t=this._parseStack.handlers;let n=this._parseStack.handlerPos-1;switch(this._parseStack.state){case 3:if(!1===i&&n>-1)for(;n>=0&&(s=t[n](this._params),!0!==s);n--)if(s instanceof Promise)return this._parseStack.handlerPos=n,s;this._parseStack.handlers=[];break;case 4:if(!1===i&&n>-1)for(;n>=0&&(s=t[n](),!0!==s);n--)if(s instanceof Promise)return this._parseStack.handlerPos=n,s;this._parseStack.handlers=[];break;case 6:if(r=e[this._parseStack.chunkPos],s=this._dcsParser.unhook(24!==r&&26!==r,i),s)return s;27===r&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0;break;case 5:if(r=e[this._parseStack.chunkPos],s=this._oscParser.end(24!==r&&26!==r,i),s)return s;27===r&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0}this._parseStack.state=0,o=this._parseStack.chunkPos+1,this.precedingJoinState=0,this.currentState=15&this._parseStack.transition}for(let i=o;i>4){case 2:for(let s=i+1;;++s){if(s>=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=0&&(s=o[a](this._params),!0!==s);a--)if(s instanceof Promise)return this._preserveStack(3,o,a,n,i),s;a<0&&this._csiHandlerFb(this._collect<<8|r,this._params),this.precedingJoinState=0;break;case 8:do{switch(r){case 59:this._params.addParam(0);break;case 58:this._params.addSubParam(-1);break;default:this._params.addDigit(r-48)}}while(++i47&&r<60);i--;break;case 9:this._collect<<=8,this._collect|=r;break;case 10:const h=this._escHandlers[this._collect<<8|r];let c=h?h.length-1:-1;for(;c>=0&&(s=h[c](),!0!==s);c--)if(s instanceof Promise)return this._preserveStack(4,h,c,n,i),s;c<0&&this._escHandlerFb(this._collect<<8|r),this.precedingJoinState=0;break;case 11:this._params.reset(),this._params.addParam(0),this._collect=0;break;case 12:this._dcsParser.hook(this._collect<<8|r,this._params);break;case 13:for(let s=i+1;;++s)if(s>=t||24===(r=e[s])||26===r||27===r||r>127&&r=t||(r=e[s])<32||r>127&&r{Object.defineProperty(t,"__esModule",{value:!0}),t.OscHandler=t.OscParser=void 0;const s=i(1263),r=i(726),n=[];t.OscParser=class{constructor(){this._state=0,this._active=n,this._id=-1,this._handlers=Object.create(null),this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(e,t){void 0===this._handlers[e]&&(this._handlers[e]=[]);const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=n}reset(){if(2===this._state)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=n,this._id=-1,this._state=0}_start(){if(this._active=this._handlers[this._id]||n,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].start();else this._handlerFb(this._id,"START")}_put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._id,"PUT",(0,r.utf32ToString)(e,t,i))}start(){this.reset(),this._state=1}put(e,t,i){if(3!==this._state){if(1===this._state)for(;t0&&this._put(e,t,i)}}end(e,t=!0){if(0!==this._state){if(3!==this._state)if(1===this._state&&this._start(),this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].end(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].end(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._id,"END",e);this._active=n,this._id=-1,this._state=0}}},t.OscHandler=class{constructor(e){this._handler=e,this._data="",this._hitLimit=!1}start(){this._data="",this._hitLimit=!1}put(e,t,i){this._hitLimit||(this._data+=(0,r.utf32ToString)(e,t,i),this._data.length>s.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))}end(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data),t instanceof Promise))return t.then((e=>(this._data="",this._hitLimit=!1,e)));return this._data="",this._hitLimit=!1,t}}},7262:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Params=void 0;const i=2147483647;class s{static fromArray(e){const t=new s;if(!e.length)return t;for(let i=Array.isArray(e[0])?1:0;i256)throw new Error("maxSubParamsLength must not be greater than 256");this.params=new Int32Array(e),this.length=0,this._subParams=new Int32Array(t),this._subParamsLength=0,this._subParamsIdx=new Uint16Array(e),this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}clone(){const e=new s(this.maxLength,this.maxSubParamsLength);return e.params.set(this.params),e.length=this.length,e._subParams.set(this._subParams),e._subParamsLength=this._subParamsLength,e._subParamsIdx.set(this._subParamsIdx),e._rejectDigits=this._rejectDigits,e._rejectSubDigits=this._rejectSubDigits,e._digitIsSub=this._digitIsSub,e}toArray(){const e=[];for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&e.push(Array.prototype.slice.call(this._subParams,i,s))}return e}reset(){this.length=0,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}addParam(e){if(this._digitIsSub=!1,this.length>=this.maxLength)this._rejectDigits=!0;else{if(e<-1)throw new Error("values lesser than -1 are not allowed");this._subParamsIdx[this.length]=this._subParamsLength<<8|this._subParamsLength,this.params[this.length++]=e>i?i:e}}addSubParam(e){if(this._digitIsSub=!0,this.length)if(this._rejectDigits||this._subParamsLength>=this.maxSubParamsLength)this._rejectSubDigits=!0;else{if(e<-1)throw new Error("values lesser than -1 are not allowed");this._subParams[this._subParamsLength++]=e>i?i:e,this._subParamsIdx[this.length-1]++}}hasSubParams(e){return(255&this._subParamsIdx[e])-(this._subParamsIdx[e]>>8)>0}getSubParams(e){const t=this._subParamsIdx[e]>>8,i=255&this._subParamsIdx[e];return i-t>0?this._subParams.subarray(t,i):null}getSubParamsAll(){const e={};for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&(e[t]=this._subParams.slice(i,s))}return e}addDigit(e){let t;if(this._rejectDigits||!(t=this._digitIsSub?this._subParamsLength:this.length)||this._digitIsSub&&this._rejectSubDigits)return;const s=this._digitIsSub?this._subParams:this.params,r=s[t-1];s[t-1]=~r?Math.min(10*r+e,i):e}}t.Params=s},3027:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.AddonManager=void 0,t.AddonManager=class{constructor(){this._addons=[]}dispose(){for(let e=this._addons.length-1;e>=0;e--)this._addons[e].instance.dispose()}loadAddon(e,t){const i={instance:t,dispose:t.dispose,isDisposed:!1};this._addons.push(i),t.dispose=()=>this._wrappedAddonDispose(i),t.activate(e)}_wrappedAddonDispose(e){if(e.isDisposed)return;let t=-1;for(let i=0;i{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferApiView=void 0;const s=i(793),r=i(3055);t.BufferApiView=class{constructor(e,t){this._buffer=e,this.type=t}init(e){return this._buffer=e,this}get cursorY(){return this._buffer.y}get cursorX(){return this._buffer.x}get viewportY(){return this._buffer.ydisp}get baseY(){return this._buffer.ybase}get length(){return this._buffer.lines.length}getLine(e){const t=this._buffer.lines.get(e);if(t)return new s.BufferLineApiView(t)}getNullCell(){return new r.CellData}}},793:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLineApiView=void 0;const s=i(3055);t.BufferLineApiView=class{constructor(e){this._line=e}get isWrapped(){return this._line.isWrapped}get length(){return this._line.length}getCell(e,t){if(!(e<0||e>=this._line.length))return t?(this._line.loadCell(e,t),t):this._line.loadCell(e,new s.CellData)}translateToString(e,t,i){return this._line.translateToString(e,t,i)}}},5101:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferNamespaceApi=void 0;const s=i(3235),r=i(7150),n=i(802);class o extends r.Disposable{constructor(e){super(),this._core=e,this._onBufferChange=this._register(new n.Emitter),this.onBufferChange=this._onBufferChange.event,this._normal=new s.BufferApiView(this._core.buffers.normal,"normal"),this._alternate=new s.BufferApiView(this._core.buffers.alt,"alternate"),this._core.buffers.onBufferActivate((()=>this._onBufferChange.fire(this.active)))}get active(){if(this._core.buffers.active===this._core.buffers.normal)return this.normal;if(this._core.buffers.active===this._core.buffers.alt)return this.alternate;throw new Error("Active buffer is neither normal nor alternate")}get normal(){return this._normal.init(this._core.buffers.normal)}get alternate(){return this._alternate.init(this._core.buffers.alt)}}t.BufferNamespaceApi=o},6097:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ParserApi=void 0,t.ParserApi=class{constructor(e){this._core=e}registerCsiHandler(e,t){return this._core.registerCsiHandler(e,(e=>t(e.toArray())))}addCsiHandler(e,t){return this.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._core.registerDcsHandler(e,((e,i)=>t(e,i.toArray())))}addDcsHandler(e,t){return this.registerDcsHandler(e,t)}registerEscHandler(e,t){return this._core.registerEscHandler(e,t)}addEscHandler(e,t){return this.registerEscHandler(e,t)}registerOscHandler(e,t){return this._core.registerOscHandler(e,t)}addOscHandler(e,t){return this.registerOscHandler(e,t)}}},4335:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeApi=void 0,t.UnicodeApi=class{constructor(e){this._core=e}register(e){this._core.unicodeService.register(e)}get versions(){return this._core.unicodeService.versions}get activeVersion(){return this._core.unicodeService.activeVersion}set activeVersion(e){this._core.unicodeService.activeVersion=e}}},9640:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferService=t.MINIMUM_ROWS=t.MINIMUM_COLS=void 0;const n=i(7150),o=i(4097),a=i(6501),l=i(802);t.MINIMUM_COLS=2,t.MINIMUM_ROWS=1;let h=class extends n.Disposable{get buffer(){return this.buffers.active}constructor(e){super(),this.isUserScrolling=!1,this._onResize=this._register(new l.Emitter),this.onResize=this._onResize.event,this._onScroll=this._register(new l.Emitter),this.onScroll=this._onScroll.event,this.cols=Math.max(e.rawOptions.cols||0,t.MINIMUM_COLS),this.rows=Math.max(e.rawOptions.rows||0,t.MINIMUM_ROWS),this.buffers=this._register(new o.BufferSet(e,this)),this._register(this.buffers.onBufferActivate((e=>{this._onScroll.fire(e.activeBuffer.ydisp)})))}resize(e,t){const i=this.cols!==e,s=this.rows!==t;this.cols=e,this.rows=t,this.buffers.resize(e,t),this._onResize.fire({cols:e,rows:t,colsChanged:i,rowsChanged:s})}reset(){this.buffers.reset(),this.isUserScrolling=!1}scroll(e,t=!1){const i=this.buffer;let s;s=this._cachedBlankLine,s&&s.length===this.cols&&s.getFg(0)===e.fg&&s.getBg(0)===e.bg||(s=i.getBlankLine(e,t),this._cachedBlankLine=s),s.isWrapped=t;const r=i.ybase+i.scrollTop,n=i.ybase+i.scrollBottom;if(0===i.scrollTop){const e=i.lines.isFull;n===i.lines.length-1?e?i.lines.recycle().copyFrom(s):i.lines.push(s.clone()):i.lines.splice(n+1,0,s.clone()),e?this.isUserScrolling&&(i.ydisp=Math.max(i.ydisp-1,0)):(i.ybase++,this.isUserScrolling||i.ydisp++)}else{const e=n-r+1;i.lines.shiftElements(r+1,e-1,-1),i.lines.set(n,s.clone())}this.isUserScrolling||(i.ydisp=i.ybase),this._onScroll.fire(i.ydisp)}scrollLines(e,t){const i=this.buffer;if(e<0){if(0===i.ydisp)return;this.isUserScrolling=!0}else e+i.ydisp>=i.ybase&&(this.isUserScrolling=!1);const s=i.ydisp;i.ydisp=Math.max(Math.min(i.ydisp+e,i.ybase),0),s!==i.ydisp&&(t||this._onScroll.fire(i.ydisp))}};t.BufferService=h,t.BufferService=h=s([r(0,a.IOptionsService)],h)},5746:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CharsetService=void 0,t.CharsetService=class{constructor(){this.glevel=0,this._charsets=[]}reset(){this.charset=void 0,this._charsets=[],this.glevel=0}setgLevel(e){this.glevel=e,this.charset=this._charsets[e]}setgCharset(e,t){this._charsets[e]=t,this.glevel===e&&(this.charset=t)}}},7792:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreMouseService=void 0;const n=i(6501),o=i(7150),a=i(802),l={NONE:{events:0,restrict:()=>!1},X10:{events:1,restrict:e=>4!==e.button&&1===e.action&&(e.ctrl=!1,e.alt=!1,e.shift=!1,!0)},VT200:{events:19,restrict:e=>32!==e.action},DRAG:{events:23,restrict:e=>32!==e.action||3!==e.button},ANY:{events:31,restrict:e=>!0}};function h(e,t){let i=(e.ctrl?16:0)|(e.shift?4:0)|(e.alt?8:0);return 4===e.button?(i|=64,i|=e.action):(i|=3&e.button,4&e.button&&(i|=64),8&e.button&&(i|=128),32===e.action?i|=32:0!==e.action||t||(i|=3)),i}const c=String.fromCharCode,d={DEFAULT:e=>{const t=[h(e,!1)+32,e.col+32,e.row+32];return t[0]>255||t[1]>255||t[2]>255?"":`${c(t[0])}${c(t[1])}${c(t[2])}`},SGR:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${h(e,!0)};${e.col};${e.row}${t}`},SGR_PIXELS:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${h(e,!0)};${e.x};${e.y}${t}`}};let u=class extends o.Disposable{constructor(e,t,i){super(),this._bufferService=e,this._coreService=t,this._optionsService=i,this._protocols={},this._encodings={},this._activeProtocol="",this._activeEncoding="",this._lastEvent=null,this._wheelPartialScroll=0,this._onProtocolChange=this._register(new a.Emitter),this.onProtocolChange=this._onProtocolChange.event;for(const e of Object.keys(l))this.addProtocol(e,l[e]);for(const e of Object.keys(d))this.addEncoding(e,d[e]);this.reset()}addProtocol(e,t){this._protocols[e]=t}addEncoding(e,t){this._encodings[e]=t}get activeProtocol(){return this._activeProtocol}get areMouseEventsActive(){return 0!==this._protocols[this._activeProtocol].events}set activeProtocol(e){if(!this._protocols[e])throw new Error(`unknown protocol "${e}"`);this._activeProtocol=e,this._onProtocolChange.fire(this._protocols[e].events)}get activeEncoding(){return this._activeEncoding}set activeEncoding(e){if(!this._encodings[e])throw new Error(`unknown encoding "${e}"`);this._activeEncoding=e}reset(){this.activeProtocol="NONE",this.activeEncoding="DEFAULT",this._lastEvent=null,this._wheelPartialScroll=0}consumeWheelEvent(e,t,i){if(0===e.deltaY||e.shiftKey)return 0;if(void 0===t||void 0===i)return 0;const s=t/i;let r=this._applyScrollModifier(e.deltaY,e);return e.deltaMode===WheelEvent.DOM_DELTA_PIXEL?(r/=s+0,Math.abs(e.deltaY)<50&&(r*=.3),this._wheelPartialScroll+=r,r=Math.floor(Math.abs(this._wheelPartialScroll))*(this._wheelPartialScroll>0?1:-1),this._wheelPartialScroll%=1):e.deltaMode===WheelEvent.DOM_DELTA_PAGE&&(r*=this._bufferService.rows),r}_applyScrollModifier(e,t){return t.altKey||t.ctrlKey||t.shiftKey?e*this._optionsService.rawOptions.fastScrollSensitivity*this._optionsService.rawOptions.scrollSensitivity:e*this._optionsService.rawOptions.scrollSensitivity}triggerMouseEvent(e){if(e.col<0||e.col>=this._bufferService.cols||e.row<0||e.row>=this._bufferService.rows)return!1;if(4===e.button&&32===e.action)return!1;if(3===e.button&&32!==e.action)return!1;if(4!==e.button&&(2===e.action||3===e.action))return!1;if(e.col++,e.row++,32===e.action&&this._lastEvent&&this._equalEvents(this._lastEvent,e,"SGR_PIXELS"===this._activeEncoding))return!1;if(!this._protocols[this._activeProtocol].restrict(e))return!1;const t=this._encodings[this._activeEncoding](e);return t&&("DEFAULT"===this._activeEncoding?this._coreService.triggerBinaryEvent(t):this._coreService.triggerDataEvent(t,!0)),this._lastEvent=e,!0}explainEvents(e){return{down:!!(1&e),up:!!(2&e),drag:!!(4&e),move:!!(8&e),wheel:!!(16&e)}}_equalEvents(e,t,i){if(i){if(e.x!==t.x)return!1;if(e.y!==t.y)return!1}else{if(e.col!==t.col)return!1;if(e.row!==t.row)return!1}return e.button===t.button&&e.action===t.action&&e.ctrl===t.ctrl&&e.alt===t.alt&&e.shift===t.shift}};t.CoreMouseService=u,t.CoreMouseService=u=s([r(0,n.IBufferService),r(1,n.ICoreService),r(2,n.IOptionsService)],u)},4071:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreService=void 0;const n=i(7453),o=i(7150),a=i(6501),l=i(802),h=Object.freeze({insertMode:!1}),c=Object.freeze({applicationCursorKeys:!1,applicationKeypad:!1,bracketedPasteMode:!1,cursorBlink:void 0,cursorStyle:void 0,origin:!1,reverseWraparound:!1,sendFocus:!1,synchronizedOutput:!1,wraparound:!0});let d=class extends o.Disposable{constructor(e,t,i){super(),this._bufferService=e,this._logService=t,this._optionsService=i,this.isCursorInitialized=!1,this.isCursorHidden=!1,this._onData=this._register(new l.Emitter),this.onData=this._onData.event,this._onUserInput=this._register(new l.Emitter),this.onUserInput=this._onUserInput.event,this._onBinary=this._register(new l.Emitter),this.onBinary=this._onBinary.event,this._onRequestScrollToBottom=this._register(new l.Emitter),this.onRequestScrollToBottom=this._onRequestScrollToBottom.event,this.modes=(0,n.clone)(h),this.decPrivateModes=(0,n.clone)(c)}reset(){this.modes=(0,n.clone)(h),this.decPrivateModes=(0,n.clone)(c)}triggerDataEvent(e,t=!1){if(this._optionsService.rawOptions.disableStdin)return;const i=this._bufferService.buffer;t&&this._optionsService.rawOptions.scrollOnUserInput&&i.ybase!==i.ydisp&&this._onRequestScrollToBottom.fire(),t&&this._onUserInput.fire(),this._logService.debug(`sending data "${e}"`),this._logService.trace("sending data (codes)",(()=>e.split("").map((e=>e.charCodeAt(0))))),this._onData.fire(e)}triggerBinaryEvent(e){this._optionsService.rawOptions.disableStdin||(this._logService.debug(`sending binary "${e}"`),this._logService.trace("sending binary (codes)",(()=>e.split("").map((e=>e.charCodeAt(0))))),this._onBinary.fire(e))}};t.CoreService=d,t.CoreService=d=s([r(0,a.IBufferService),r(1,a.ILogService),r(2,a.IOptionsService)],d)},4720:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DecorationService=void 0;const s=i(4103),r=i(7150),n=i(3087),o=i(802);let a=0,l=0;class h extends r.Disposable{get decorations(){return this._decorations.values()}constructor(){super(),this._decorations=new n.SortedList((e=>e?.marker.line)),this._onDecorationRegistered=this._register(new o.Emitter),this.onDecorationRegistered=this._onDecorationRegistered.event,this._onDecorationRemoved=this._register(new o.Emitter),this.onDecorationRemoved=this._onDecorationRemoved.event,this._register((0,r.toDisposable)((()=>this.reset())))}registerDecoration(e){if(e.marker.isDisposed)return;const t=new c(e);if(t){const e=t.marker.onDispose((()=>t.dispose())),i=t.onDispose((()=>{i.dispose(),t&&(this._decorations.delete(t)&&this._onDecorationRemoved.fire(t),e.dispose())}));this._decorations.insert(t),this._onDecorationRegistered.fire(t)}return t}reset(){for(const e of this._decorations.values())e.dispose();this._decorations.clear()}*getDecorationsAtCell(e,t,i){let s=0,r=0;for(const n of this._decorations.getKeyIterator(t))s=n.options.x??0,r=s+(n.options.width??1),e>=s&&e{a=t.options.x??0,l=a+(t.options.width??1),e>=a&&e{Object.defineProperty(t,"__esModule",{value:!0}),t.InstantiationService=t.ServiceCollection=void 0;const s=i(6501),r=i(6201);class n{constructor(...e){this._entries=new Map;for(const[t,i]of e)this.set(t,i)}set(e,t){const i=this._entries.get(e);return this._entries.set(e,t),i}forEach(e){for(const[t,i]of this._entries.entries())e(t,i)}has(e){return this._entries.has(e)}get(e){return this._entries.get(e)}}t.ServiceCollection=n,t.InstantiationService=class{constructor(){this._services=new n,this._services.set(s.IInstantiationService,this)}setService(e,t){this._services.set(e,t)}getService(e){return this._services.get(e)}createInstance(e,...t){const i=(0,r.getServiceDependencies)(e).sort(((e,t)=>e.index-t.index)),s=[];for(const t of i){const i=this._services.get(t.id);if(!i)throw new Error(`[createInstance] ${e.name} depends on UNKNOWN service ${t.id._id}.`);s.push(i)}const n=i.length>0?i[0].index:t.length;if(t.length!==n)throw new Error(`[createInstance] First service dependency of ${e.name} at position ${n+1} conflicts with ${t.length} static arguments`);return new e(...[...t,...s])}}},7276:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.LogService=void 0,t.setTraceLogger=function(e){l=e},t.traceCall=function(e,t,i){if("function"!=typeof i.value)throw new Error("not supported");const s=i.value;i.value=function(...e){if(l.logLevel!==o.LogLevelEnum.TRACE)return s.apply(this,e);l.trace(`GlyphRenderer#${s.name}(${e.map((e=>JSON.stringify(e))).join(", ")})`);const t=s.apply(this,e);return l.trace(`GlyphRenderer#${s.name} return`,t),t}};const n=i(7150),o=i(6501),a={trace:o.LogLevelEnum.TRACE,debug:o.LogLevelEnum.DEBUG,info:o.LogLevelEnum.INFO,warn:o.LogLevelEnum.WARN,error:o.LogLevelEnum.ERROR,off:o.LogLevelEnum.OFF};let l,h=class extends n.Disposable{get logLevel(){return this._logLevel}constructor(e){super(),this._optionsService=e,this._logLevel=o.LogLevelEnum.OFF,this._updateLogLevel(),this._register(this._optionsService.onSpecificOptionChange("logLevel",(()=>this._updateLogLevel()))),l=this}_updateLogLevel(){this._logLevel=a[this._optionsService.rawOptions.logLevel]}_evalLazyOptionalParams(e){for(let t=0;t{Object.defineProperty(t,"__esModule",{value:!0}),t.OptionsService=t.DEFAULT_OPTIONS=void 0;const s=i(7150),r=i(701),n=i(802);t.DEFAULT_OPTIONS={cols:80,rows:24,cursorBlink:!1,cursorStyle:"block",cursorWidth:1,cursorInactiveStyle:"outline",customGlyphs:!0,drawBoldTextInBrightColors:!0,documentOverride:null,fastScrollModifier:"alt",fastScrollSensitivity:5,fontFamily:"monospace",fontSize:15,fontWeight:"normal",fontWeightBold:"bold",ignoreBracketedPasteMode:!1,lineHeight:1,letterSpacing:0,linkHandler:null,logLevel:"info",logger:null,scrollback:1e3,scrollOnEraseInDisplay:!1,scrollOnUserInput:!0,scrollSensitivity:1,screenReaderMode:!1,smoothScrollDuration:0,macOptionIsMeta:!1,macOptionClickForcesSelection:!1,minimumContrastRatio:1,disableStdin:!1,allowProposedApi:!1,allowTransparency:!1,tabStopWidth:8,theme:{},reflowCursorLine:!1,rescaleOverlappingGlyphs:!1,rightClickSelectsWord:r.isMac,windowOptions:{},windowsMode:!1,windowsPty:{},wordSeparator:" ()[]{}',\"`",altClickMovesCursor:!0,convertEol:!1,termName:"xterm",cancelEvents:!1,overviewRuler:{}};const o=["normal","bold","100","200","300","400","500","600","700","800","900"];class a extends s.Disposable{constructor(e){super(),this._onOptionChange=this._register(new n.Emitter),this.onOptionChange=this._onOptionChange.event;const i={...t.DEFAULT_OPTIONS};for(const t in e)if(t in i)try{const s=e[t];i[t]=this._sanitizeAndValidateOption(t,s)}catch(e){console.error(e)}this.rawOptions=i,this.options={...i},this._setupOptions(),this._register((0,s.toDisposable)((()=>{this.rawOptions.linkHandler=null,this.rawOptions.documentOverride=null})))}onSpecificOptionChange(e,t){return this.onOptionChange((i=>{i===e&&t(this.rawOptions[e])}))}onMultipleOptionChange(e,t){return this.onOptionChange((i=>{-1!==e.indexOf(i)&&t()}))}_setupOptions(){const e=e=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);return this.rawOptions[e]},i=(e,i)=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);i=this._sanitizeAndValidateOption(e,i),this.rawOptions[e]!==i&&(this.rawOptions[e]=i,this._onOptionChange.fire(e))};for(const t in this.rawOptions){const s={get:e.bind(this,t),set:i.bind(this,t)};Object.defineProperty(this.options,t,s)}}_sanitizeAndValidateOption(e,i){switch(e){case"cursorStyle":if(i||(i=t.DEFAULT_OPTIONS[e]),!function(e){return"block"===e||"underline"===e||"bar"===e}(i))throw new Error(`"${i}" is not a valid value for ${e}`);break;case"wordSeparator":i||(i=t.DEFAULT_OPTIONS[e]);break;case"fontWeight":case"fontWeightBold":if("number"==typeof i&&1<=i&&i<=1e3)break;i=o.includes(i)?i:t.DEFAULT_OPTIONS[e];break;case"cursorWidth":i=Math.floor(i);case"lineHeight":case"tabStopWidth":if(i<1)throw new Error(`${e} cannot be less than 1, value: ${i}`);break;case"minimumContrastRatio":i=Math.max(1,Math.min(21,Math.round(10*i)/10));break;case"scrollback":if((i=Math.min(i,4294967295))<0)throw new Error(`${e} cannot be less than 0, value: ${i}`);break;case"fastScrollSensitivity":case"scrollSensitivity":if(i<=0)throw new Error(`${e} cannot be less than or equal to 0, value: ${i}`);break;case"rows":case"cols":if(!i&&0!==i)throw new Error(`${e} must be numeric, value: ${i}`);break;case"windowsPty":i=i??{}}return i}}t.OptionsService=a},8811:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkService=void 0;const n=i(6501);let o=class{constructor(e){this._bufferService=e,this._nextId=1,this._entriesWithId=new Map,this._dataByLinkId=new Map}registerLink(e){const t=this._bufferService.buffer;if(void 0===e.id){const i=t.addMarker(t.ybase+t.y),s={data:e,id:this._nextId++,lines:[i]};return i.onDispose((()=>this._removeMarkerFromLink(s,i))),this._dataByLinkId.set(s.id,s),s.id}const i=e,s=this._getEntryIdKey(i),r=this._entriesWithId.get(s);if(r)return this.addLineToLink(r.id,t.ybase+t.y),r.id;const n=t.addMarker(t.ybase+t.y),o={id:this._nextId++,key:this._getEntryIdKey(i),data:i,lines:[n]};return n.onDispose((()=>this._removeMarkerFromLink(o,n))),this._entriesWithId.set(o.key,o),this._dataByLinkId.set(o.id,o),o.id}addLineToLink(e,t){const i=this._dataByLinkId.get(e);if(i&&i.lines.every((e=>e.line!==t))){const e=this._bufferService.buffer.addMarker(t);i.lines.push(e),e.onDispose((()=>this._removeMarkerFromLink(i,e)))}}getLinkData(e){return this._dataByLinkId.get(e)?.data}_getEntryIdKey(e){return`${e.id};;${e.uri}`}_removeMarkerFromLink(e,t){const i=e.lines.indexOf(t);-1!==i&&(e.lines.splice(i,1),0===e.lines.length&&(void 0!==e.data.id&&this._entriesWithId.delete(e.key),this._dataByLinkId.delete(e.id)))}};t.OscLinkService=o,t.OscLinkService=o=s([r(0,n.IBufferService)],o)},6201:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.serviceRegistry=void 0,t.getServiceDependencies=function(e){return e[s]||[]},t.createDecorator=function(e){if(t.serviceRegistry.has(e))return t.serviceRegistry.get(e);const r=function(e,t,n){if(3!==arguments.length)throw new Error("@IServiceName-decorator can only be used to decorate a parameter");!function(e,t,r){t[i]===t?t[s].push({id:e,index:r}):(t[s]=[{id:e,index:r}],t[i]=t)}(r,e,n)};return r._id=e,t.serviceRegistry.set(e,r),r};const i="di$target",s="di$dependencies";t.serviceRegistry=new Map},6501:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.IDecorationService=t.IUnicodeService=t.IOscLinkService=t.IOptionsService=t.ILogService=t.LogLevelEnum=t.IInstantiationService=t.ICharsetService=t.ICoreService=t.ICoreMouseService=t.IBufferService=void 0;const s=i(6201);var r;t.IBufferService=(0,s.createDecorator)("BufferService"),t.ICoreMouseService=(0,s.createDecorator)("CoreMouseService"),t.ICoreService=(0,s.createDecorator)("CoreService"),t.ICharsetService=(0,s.createDecorator)("CharsetService"),t.IInstantiationService=(0,s.createDecorator)("InstantiationService"),function(e){e[e.TRACE=0]="TRACE",e[e.DEBUG=1]="DEBUG",e[e.INFO=2]="INFO",e[e.WARN=3]="WARN",e[e.ERROR=4]="ERROR",e[e.OFF=5]="OFF"}(r||(t.LogLevelEnum=r={})),t.ILogService=(0,s.createDecorator)("LogService"),t.IOptionsService=(0,s.createDecorator)("OptionsService"),t.IOscLinkService=(0,s.createDecorator)("OscLinkService"),t.IUnicodeService=(0,s.createDecorator)("UnicodeService"),t.IDecorationService=(0,s.createDecorator)("DecorationService")},6415:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeService=void 0;const s=i(7428),r=i(802);class n{static extractShouldJoin(e){return!!(1&e)}static extractWidth(e){return e>>1&3}static extractCharKind(e){return e>>3}static createPropertyValue(e,t,i=!1){return(16777215&e)<<3|(3&t)<<1|(i?1:0)}constructor(){this._providers=Object.create(null),this._active="",this._onChange=new r.Emitter,this.onChange=this._onChange.event;const e=new s.UnicodeV6;this.register(e),this._active=e.version,this._activeProvider=e}dispose(){this._onChange.dispose()}get versions(){return Object.keys(this._providers)}get activeVersion(){return this._active}set activeVersion(e){if(!this._providers[e])throw new Error(`unknown Unicode version "${e}"`);this._active=e,this._activeProvider=this._providers[e],this._onChange.fire(e)}register(e){this._providers[e.version]=e}wcwidth(e){return this._activeProvider.wcwidth(e)}getStringCellWidth(e){let t=0,i=0;const s=e.length;for(let r=0;r=s)return t+this.wcwidth(o);const i=e.charCodeAt(r);56320<=i&&i<=57343?o=1024*(o-55296)+i-56320+65536:t+=this.wcwidth(i)}const a=this.charProperties(o,i);let l=n.extractWidth(a);n.extractShouldJoin(a)&&(l-=n.extractWidth(i)),t+=l,i=a}return t}charProperties(e,t){return this._activeProvider.charProperties(e,t)}}t.UnicodeService=n},4333:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.isAndroid=t.isElectron=t.isWebkitWebView=t.isSafari=t.isChrome=t.isWebKit=t.isFirefox=t.onDidChangeFullscreen=t.onDidChangeZoomLevel=void 0,t.addMatchMediaChangeListener=o,t.setZoomLevel=function(e,t){n.INSTANCE.setZoomLevel(e,t)},t.getZoomLevel=function(e){return n.INSTANCE.getZoomLevel(e)},t.getZoomFactor=function(e){return n.INSTANCE.getZoomFactor(e)},t.setZoomFactor=function(e,t){n.INSTANCE.setZoomFactor(e,t)},t.setFullscreen=function(e,t){n.INSTANCE.setFullscreen(e,t)},t.isFullscreen=function(e){return n.INSTANCE.isFullscreen(e)},t.isStandalone=function(){return l},t.isWCOEnabled=function(){return navigator?.windowControlsOverlay?.visible},t.getWCOBoundingRect=function(){return navigator?.windowControlsOverlay?.getTitlebarAreaRect()};const s=i(4693),r=i(802);class n{constructor(){this.mapWindowIdToZoomLevel=new Map,this._onDidChangeZoomLevel=new r.Emitter,this.onDidChangeZoomLevel=this._onDidChangeZoomLevel.event,this.mapWindowIdToZoomFactor=new Map,this._onDidChangeFullscreen=new r.Emitter,this.onDidChangeFullscreen=this._onDidChangeFullscreen.event,this.mapWindowIdToFullScreen=new Map}static{this.INSTANCE=new n}getZoomLevel(e){return this.mapWindowIdToZoomLevel.get(this.getWindowId(e))??0}setZoomLevel(e,t){if(this.getZoomLevel(t)===e)return;const i=this.getWindowId(t);this.mapWindowIdToZoomLevel.set(i,e),this._onDidChangeZoomLevel.fire(i)}getZoomFactor(e){return this.mapWindowIdToZoomFactor.get(this.getWindowId(e))??1}setZoomFactor(e,t){this.mapWindowIdToZoomFactor.set(this.getWindowId(t),e)}setFullscreen(e,t){if(this.isFullscreen(t)===e)return;const i=this.getWindowId(t);this.mapWindowIdToFullScreen.set(i,e),this._onDidChangeFullscreen.fire(i)}isFullscreen(e){return!!this.mapWindowIdToFullScreen.get(this.getWindowId(e))}getWindowId(e){return e.vscodeWindowId}}function o(e,t,i){"string"==typeof t&&(t=e.matchMedia(t)),t.addEventListener("change",i)}t.onDidChangeZoomLevel=n.INSTANCE.onDidChangeZoomLevel,t.onDidChangeFullscreen=n.INSTANCE.onDidChangeFullscreen;const a="object"==typeof navigator?navigator.userAgent:"";t.isFirefox=a.indexOf("Firefox")>=0,t.isWebKit=a.indexOf("AppleWebKit")>=0,t.isChrome=a.indexOf("Chrome")>=0,t.isSafari=!t.isChrome&&a.indexOf("Safari")>=0,t.isWebkitWebView=!t.isChrome&&!t.isSafari&&t.isWebKit,t.isElectron=a.indexOf("Electron/")>=0,t.isAndroid=a.indexOf("Android")>=0;let l=!1;if("function"==typeof s.mainWindow.matchMedia){const e=s.mainWindow.matchMedia("(display-mode: standalone) or (display-mode: window-controls-overlay)"),t=s.mainWindow.matchMedia("(display-mode: fullscreen)");l=e.matches,o(s.mainWindow,e,(({matches:e})=>{l&&t.matches||(l=e)}))}},7745:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.BrowserFeatures=t.KeyboardSupport=void 0;const o=n(i(4333)),a=i(4693),l=n(i(8163));var h;!function(e){e[e.Always=0]="Always",e[e.FullScreen=1]="FullScreen",e[e.None=2]="None"}(h||(t.KeyboardSupport=h={}));const c="object"==typeof navigator?navigator:{};t.BrowserFeatures={clipboard:{writeText:l.isNative||document.queryCommandSupported&&document.queryCommandSupported("copy")||!!(c&&c.clipboard&&c.clipboard.writeText),readText:l.isNative||!!(c&&c.clipboard&&c.clipboard.readText)},keyboard:l.isNative||o.isStandalone()?h.Always:c.keyboard||o.isSafari?h.FullScreen:h.None,touch:"ontouchstart"in a.mainWindow||c.maxTouchPoints>0,pointerEvents:a.mainWindow.PointerEvent&&("ontouchstart"in a.mainWindow||navigator.maxTouchPoints>0)}},7093:function(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),n=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),o=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&r(t,e,i);return n(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.SafeTriangle=t.DragAndDropObserver=t.ModifierKeyEmitter=t.DetectedFullscreenMode=t.Namespace=t.EventHelper=t.EventType=t.sharedMutationObserver=t.Dimension=t.WindowIntervalTimer=t.scheduleAtNextAnimationFrame=t.runAtThisOrScheduleAtNextAnimationFrame=t.WindowIdleValue=t.addStandardDisposableGenericMouseUpListener=t.addStandardDisposableGenericMouseDownListener=t.addStandardDisposableListener=t.onDidUnregisterWindow=t.onWillUnregisterWindow=t.onDidRegisterWindow=t.hasWindow=t.getWindowById=t.getWindowId=t.getWindowsCount=t.getWindows=t.getDocument=t.getWindow=t.registerWindow=void 0,t.clearNode=function(e){for(;e.firstChild;)e.firstChild.remove()},t.clearNodeRecursively=function e(t){for(;t.firstChild;){const i=t.firstChild;i.remove(),e(i)}},t.addDisposableListener=C,t.addDisposableGenericMouseDownListener=w,t.addDisposableGenericMouseMoveListener=function(e,i,s){return C(e,g.isIOS&&l.BrowserFeatures.pointerEvents?t.EventType.POINTER_MOVE:t.EventType.MOUSE_MOVE,i,s)},t.addDisposableGenericMouseUpListener=E,t.runWhenWindowIdle=function(e,t,i){return(0,d._runWhenIdle)(e,t,i)},t.disposableWindowInterval=function(e,t,i,s){let r=0;const n=e.setInterval((()=>{r++,("number"==typeof s&&r>=s||!0===t())&&o.dispose()}),i),o=(0,p.toDisposable)((()=>{e.clearInterval(n)}));return o},t.measure=function(e,i){return(0,t.scheduleAtNextAnimationFrame)(e,i,1e4)},t.modify=function(e,i){return(0,t.scheduleAtNextAnimationFrame)(e,i,-1e4)},t.addDisposableThrottledListener=function(e,t,i,s,r){return new T(e,t,i,s,r)},t.getComputedStyle=k,t.getClientArea=function e(i,s){const r=(0,t.getWindow)(i),n=r.document;if(i!==n.body)return new O(i.clientWidth,i.clientHeight);if(g.isIOS&&r?.visualViewport)return new O(r.visualViewport.width,r.visualViewport.height);if(r?.innerWidth&&r.innerHeight)return new O(r.innerWidth,r.innerHeight);if(n.body&&n.body.clientWidth&&n.body.clientHeight)return new O(n.body.clientWidth,n.body.clientHeight);if(n.documentElement&&n.documentElement.clientWidth&&n.documentElement.clientHeight)return new O(n.documentElement.clientWidth,n.documentElement.clientHeight);if(s)return e(s);throw new Error("Unable to figure out browser width and height")},t.getTopLeftOffset=I,t.size=function(e,t,i){"number"==typeof t&&(e.style.width=`${t}px`),"number"==typeof i&&(e.style.height=`${i}px`)},t.position=function(e,t,i,s,r,n="absolute"){"number"==typeof t&&(e.style.top=`${t}px`),"number"==typeof i&&(e.style.right=`${i}px`),"number"==typeof s&&(e.style.bottom=`${s}px`),"number"==typeof r&&(e.style.left=`${r}px`),e.style.position=n},t.getDomNodePagePosition=function(e){const i=e.getBoundingClientRect(),s=(0,t.getWindow)(e);return{left:i.left+s.scrollX,top:i.top+s.scrollY,width:i.width,height:i.height}},t.getDomNodeZoomLevel=function(e){let t=e,i=1;do{const e=k(t).zoom;null!=e&&"1"!==e&&(i*=e),t=t.parentElement}while(null!==t&&t!==t.ownerDocument.documentElement);return i},t.getTotalWidth=P,t.getContentWidth=function(e){const t=M.getBorderLeftWidth(e)+M.getBorderRightWidth(e),i=M.getPaddingLeft(e)+M.getPaddingRight(e);return e.offsetWidth-t-i},t.getTotalScrollWidth=x,t.getContentHeight=function(e){const t=M.getBorderTopWidth(e)+M.getBorderBottomWidth(e),i=M.getPaddingTop(e)+M.getPaddingBottom(e);return e.offsetHeight-t-i},t.getTotalHeight=function(e){const t=M.getMarginTop(e)+M.getMarginBottom(e);return e.offsetHeight+t},t.getLargestChildWidth=function(e,t){const i=t.map((t=>Math.max(x(t),P(t))+function(e,t){if(null===e)return 0;const i=I(e),s=I(t);return i.left-s.left}(t,e)||0));return Math.max(...i)},t.isAncestor=B,t.setParentFlowTo=function(e,t){e.dataset[N]=t.id},t.isAncestorUsingFlowTo=function(e,t){let i=e;for(;i;){if(i===t)return!0;if(Q(i)){const e=U(i);if(e){i=e;continue}}i=i.parentNode}return!1},t.findParentWithClass=F,t.hasParentWithClass=function(e,t,i){return!!F(e,t,i)},t.isShadowRoot=W,t.isInShadowDOM=function(e){return!!H(e)},t.getShadowRoot=H,t.getActiveElement=K,t.isActiveElement=function(e){return K()===e},t.isAncestorOfActiveElement=function(e){return B(K(),e)},t.isActiveDocument=function(e){return e.ownerDocument===z()},t.getActiveDocument=z,t.getActiveWindow=function(){const e=z();return e.defaultView?.window??v.mainWindow},t.isGlobalStylesheet=function(e){return j.has(e)},t.createStyleSheet2=function(){return new $},t.createStyleSheet=V,t.cloneGlobalStylesheets=function(e){const t=new p.DisposableStore;for(const[i,s]of j)t.add(G(i,s,e));return t},t.createMetaElement=function(e=v.mainWindow.document.head){return q("meta",e)},t.createLinkElement=function(e=v.mainWindow.document.head){return q("link",e)},t.createCSSRule=function e(t,i,s=Y()){if(s&&i){s.sheet?.insertRule(`${t} {${i}}`,0);for(const r of j.get(s)??[])e(t,i,r)}},t.removeCSSRulesContainingSelector=function e(t,i=Y()){if(!i)return;const s=Z(i),r=[];for(let e=0;e=0;e--)i.sheet?.deleteRule(r[e]);for(const s of j.get(i)??[])e(t,s)},t.isHTMLElement=Q,t.isHTMLAnchorElement=function(e){return e instanceof HTMLAnchorElement||e instanceof(0,t.getWindow)(e).HTMLAnchorElement},t.isHTMLSpanElement=function(e){return e instanceof HTMLSpanElement||e instanceof(0,t.getWindow)(e).HTMLSpanElement},t.isHTMLTextAreaElement=function(e){return e instanceof HTMLTextAreaElement||e instanceof(0,t.getWindow)(e).HTMLTextAreaElement},t.isHTMLInputElement=function(e){return e instanceof HTMLInputElement||e instanceof(0,t.getWindow)(e).HTMLInputElement},t.isHTMLButtonElement=function(e){return e instanceof HTMLButtonElement||e instanceof(0,t.getWindow)(e).HTMLButtonElement},t.isHTMLDivElement=function(e){return e instanceof HTMLDivElement||e instanceof(0,t.getWindow)(e).HTMLDivElement},t.isSVGElement=function(e){return e instanceof SVGElement||e instanceof(0,t.getWindow)(e).SVGElement},t.isMouseEvent=function(e){return e instanceof MouseEvent||e instanceof(0,t.getWindow)(e).MouseEvent},t.isKeyboardEvent=function(e){return e instanceof KeyboardEvent||e instanceof(0,t.getWindow)(e).KeyboardEvent},t.isPointerEvent=function(e){return e instanceof PointerEvent||e instanceof(0,t.getWindow)(e).PointerEvent},t.isDragEvent=function(e){return e instanceof DragEvent||e instanceof(0,t.getWindow)(e).DragEvent},t.isEventLike=function(e){const t=e;return!(!t||"function"!=typeof t.preventDefault||"function"!=typeof t.stopPropagation)},t.saveParentsScrollTop=function(e){const t=[];for(let i=0;e&&e.nodeType===e.ELEMENT_NODE;i++)t[i]=e.scrollTop,e=e.parentNode;return t},t.restoreParentsScrollTop=function(e,t){for(let i=0;e&&e.nodeType===e.ELEMENT_NODE;i++)e.scrollTop!==t[i]&&(e.scrollTop=t[i]),e=e.parentNode},t.trackFocus=function(e){return new ee(e)},t.after=function(e,t){return e.after(t),t},t.append=te,t.prepend=function(e,t){return e.insertBefore(t,e.firstChild),t},t.reset=function(e,...t){e.innerText="",te(e,...t)},t.$=ne,t.join=function(e,t){const i=[];return e.forEach(((e,s)=>{s>0&&(t instanceof Node?i.push(t.cloneNode()):i.push(document.createTextNode(t))),i.push(e)})),i},t.setVisibility=function(e,...t){e?oe(...t):ae(...t)},t.show=oe,t.hide=ae,t.removeTabIndexAndUpdateFocus=function(e){if(e&&e.hasAttribute("tabIndex")){if(e.ownerDocument.activeElement===e){const t=function(e){for(;e&&e.nodeType===e.ELEMENT_NODE;){if(Q(e)&&e.hasAttribute("tabIndex"))return e;e=e.parentNode}return null}(e.parentElement);t?.focus()}e.removeAttribute("tabindex")}},t.finalHandler=function(e){return t=>{t.preventDefault(),t.stopPropagation(),e(t)}},t.domContentLoaded=function(e){return new Promise((t=>{if("complete"===e.document.readyState||e.document&&null!==e.document.body)t(void 0);else{const i=()=>{e.window.removeEventListener("DOMContentLoaded",i,!1),t()};e.window.addEventListener("DOMContentLoaded",i,!1)}}))},t.computeScreenAwareSize=function(e,t){const i=e.devicePixelRatio*t;return Math.max(1,Math.floor(i))/e.devicePixelRatio},t.windowOpenNoOpener=function(e){v.mainWindow.open(e,"_blank","noopener")},t.windowOpenPopup=function(e){const t=Math.floor(v.mainWindow.screenLeft+v.mainWindow.innerWidth/2-le/2),i=Math.floor(v.mainWindow.screenTop+v.mainWindow.innerHeight/2-he/2);v.mainWindow.open(e,"_blank",`width=${le},height=${he},top=${i},left=${t}`)},t.windowOpenWithSuccess=function(e,t=!0){const i=v.mainWindow.open();return!!i&&(t&&(i.opener=null),i.location.href=e,!0)},t.animate=function(e,i){const s=()=>{i(),r=(0,t.scheduleAtNextAnimationFrame)(e,s)};let r=(0,t.scheduleAtNextAnimationFrame)(e,s);return(0,p.toDisposable)((()=>r.dispose()))},t.asCSSPropertyValue=function(e){return`'${e.replace(/'/g,"%27")}'`},t.asCssValueWithDefault=function e(t,i){if(void 0!==t){const s=t.match(/^\s*var\((.+)\)$/);if(s){const t=s[1].split(",",2);return 2===t.length&&(i=e(t[1].trim(),i)),`var(${t[0]}, ${i})`}return t}return i},t.detectFullscreen=function(e){return e.document.fullscreenElement||e.document.webkitFullscreenElement||e.document.webkitIsFullScreen?{mode:ce.DOCUMENT,guess:!1}:e.innerHeight===e.screen.height?{mode:ce.BROWSER,guess:!1}:(g.isMacintosh||g.isLinux)&&e.outerHeight===e.screen.height&&e.outerWidth===e.screen.width?{mode:ce.BROWSER,guess:!0}:null},t.multibyteAwareBtoa=function(e){return btoa(function(e){const t=new Uint16Array(e.length);for(let i=0;i0&&(o.className=a.join(" "));const l={};if(r.groups.name&&(l[r.groups.name]=o),s)for(const e of s)Q(e)?o.appendChild(e):"string"==typeof e?o.append(e):"root"in e&&(Object.assign(l,e),o.appendChild(e.root));for(const[e,t]of Object.entries(i))if("className"!==e)if("style"===e)for(const[e,i]of Object.entries(t))o.style.setProperty(fe(e),"number"==typeof i?i+"px":""+i);else"tabIndex"===e?o.tabIndex=t:o.setAttribute(fe(e),t.toString());return l.root=o,l},t.svgElem=function(e,...t){let i,s;Array.isArray(t[0])?(i={},s=t[0]):(i=t[0]||{},s=t[1]);const r=_e.exec(e);if(!r||!r.groups)throw new Error("Bad use of h");const n=r.groups.tag||"div",o=document.createElementNS("http://www.w3.org/2000/svg",n);r.groups.id&&(o.id=r.groups.id);const a=[];if(r.groups.class)for(const e of r.groups.class.split("."))""!==e&&a.push(e);if(void 0!==i.className)for(const e of i.className.split("."))""!==e&&a.push(e);a.length>0&&(o.className=a.join(" "));const l={};if(r.groups.name&&(l[r.groups.name]=o),s)for(const e of s)Q(e)?o.appendChild(e):"string"==typeof e?o.append(e):"root"in e&&(Object.assign(l,e),o.appendChild(e.root));for(const[e,t]of Object.entries(i))if("className"!==e)if("style"===e)for(const[e,i]of Object.entries(t))o.style.setProperty(fe(e),"number"==typeof i?i+"px":""+i);else"tabIndex"===e?o.tabIndex=t:o.setAttribute(fe(e),t.toString());return l.root=o,l},t.copyAttributes=pe,t.trackAttributes=function(e,i,s){pe(e,i,s);const r=new p.DisposableStore;return r.add(t.sharedMutationObserver.observe(e,r,{attributes:!0,attributeFilter:s})((t=>{for(const s of t)"attributes"===s.type&&s.attributeName&&ge(e,i,s.attributeName)}))),r};const a=o(i(4333)),l=i(7745),h=i(5394),c=i(5964),d=i(1758),u=i(9807),_=o(i(802)),f=i(7883),p=i(7150),g=o(i(8163)),m=i(6304),v=i(4693),S=i(7704);s=function(){const e=new Map;(0,v.ensureCodeWindow)(v.mainWindow,1);const i={window:v.mainWindow,disposables:new p.DisposableStore};e.set(v.mainWindow.vscodeWindowId,i);const s=new _.Emitter,r=new _.Emitter,n=new _.Emitter;return{onDidRegisterWindow:s.event,onWillUnregisterWindow:n.event,onDidUnregisterWindow:r.event,registerWindow(i){if(e.has(i.vscodeWindowId))return p.Disposable.None;const o=new p.DisposableStore,a={window:i,disposables:o.add(new p.DisposableStore)};return e.set(i.vscodeWindowId,a),o.add((0,p.toDisposable)((()=>{e.delete(i.vscodeWindowId),r.fire(i)}))),o.add(C(i,t.EventType.BEFORE_UNLOAD,(()=>{n.fire(i)}))),s.fire(a),o},getWindows:()=>e.values(),getWindowsCount:()=>e.size,getWindowId:e=>e.vscodeWindowId,hasWindow:t=>e.has(t),getWindowById:function(t,s){return("number"==typeof t?e.get(t):void 0)??(s?i:void 0)},getWindow(e){const t=e;if(t?.ownerDocument?.defaultView)return t.ownerDocument.defaultView.window;const i=e;return i?.view?i.view.window:v.mainWindow},getDocument(e){const i=e;return(0,t.getWindow)(i).document}}}(),t.registerWindow=s.registerWindow,t.getWindow=s.getWindow,t.getDocument=s.getDocument,t.getWindows=s.getWindows,t.getWindowsCount=s.getWindowsCount,t.getWindowId=s.getWindowId,t.getWindowById=s.getWindowById,t.hasWindow=s.hasWindow,t.onDidRegisterWindow=s.onDidRegisterWindow,t.onWillUnregisterWindow=s.onWillUnregisterWindow,t.onDidUnregisterWindow=s.onDidUnregisterWindow;class b{constructor(e,t,i,s){this._node=e,this._type=t,this._handler=i,this._options=s||!1,this._node.addEventListener(this._type,this._handler,this._options)}dispose(){this._handler&&(this._node.removeEventListener(this._type,this._handler,this._options),this._node=null,this._handler=null)}}function C(e,t,i,s){return new b(e,t,i,s)}function y(e,t){return function(i){return t(new c.StandardMouseEvent(e,i))}}function w(e,i,s){return C(e,g.isIOS&&l.BrowserFeatures.pointerEvents?t.EventType.POINTER_DOWN:t.EventType.MOUSE_DOWN,i,s)}function E(e,i,s){return C(e,g.isIOS&&l.BrowserFeatures.pointerEvents?t.EventType.POINTER_UP:t.EventType.MOUSE_UP,i,s)}t.addStandardDisposableListener=function(e,i,s,r){let n=s;return"click"===i||"mousedown"===i||"contextmenu"===i?n=y((0,t.getWindow)(e),s):"keydown"!==i&&"keypress"!==i&&"keyup"!==i||(n=function(e){return function(t){return e(new h.StandardKeyboardEvent(t))}}(s)),C(e,i,n,r)},t.addStandardDisposableGenericMouseDownListener=function(e,i,s){return w(e,y((0,t.getWindow)(e),i),s)},t.addStandardDisposableGenericMouseUpListener=function(e,i,s){return E(e,y((0,t.getWindow)(e),i),s)};class D extends d.AbstractIdleValue{constructor(e,t){super(e,t)}}t.WindowIdleValue=D;class L extends d.IntervalTimer{constructor(e){super(),this.defaultTarget=e&&(0,t.getWindow)(e)}cancelAndSet(e,t,i){return super.cancelAndSet(e,t,i??this.defaultTarget)}}t.WindowIntervalTimer=L;class R{constructor(e,t=0){this._runner=e,this.priority=t,this._canceled=!1}dispose(){this._canceled=!0}execute(){if(!this._canceled)try{this._runner()}catch(e){(0,u.onUnexpectedError)(e)}}static sort(e,t){return t.priority-e.priority}}!function(){const e=new Map,i=new Map,s=new Map,r=new Map;t.scheduleAtNextAnimationFrame=(n,o,a=0)=>{const l=(0,t.getWindowId)(n),h=new R(o,a);let c=e.get(l);return c||(c=[],e.set(l,c)),c.push(h),s.get(l)||(s.set(l,!0),n.requestAnimationFrame((()=>(t=>{s.set(t,!1);const n=e.get(t)??[];for(i.set(t,n),e.set(t,[]),r.set(t,!0);n.length>0;)n.sort(R.sort),n.shift().execute();r.set(t,!1)})(l)))),h},t.runAtThisOrScheduleAtNextAnimationFrame=(e,s,n)=>{const o=(0,t.getWindowId)(e);if(r.get(o)){const e=new R(s,n);let t=i.get(o);return t||(t=[],i.set(o,t)),t.push(e),e}return(0,t.scheduleAtNextAnimationFrame)(e,s,n)}}();const A=function(e,t){return t};class T extends p.Disposable{constructor(e,t,i,s=A,r=8){super();let n=null,o=0;const a=this._register(new d.TimeoutTimer),l=()=>{o=(new Date).getTime(),i(n),n=null};this._register(C(e,t,(e=>{n=s(n,e);const t=(new Date).getTime()-o;t>=r?(a.cancel(),l()):a.setIfNotSet(l,r-t)})))}}function k(e){return(0,t.getWindow)(e).getComputedStyle(e,null)}class M{static convertToPixels(e,t){return parseFloat(t)||0}static getDimension(e,t,i){const s=k(e),r=s?s.getPropertyValue(t):"0";return M.convertToPixels(e,r)}static getBorderLeftWidth(e){return M.getDimension(e,"border-left-width","borderLeftWidth")}static getBorderRightWidth(e){return M.getDimension(e,"border-right-width","borderRightWidth")}static getBorderTopWidth(e){return M.getDimension(e,"border-top-width","borderTopWidth")}static getBorderBottomWidth(e){return M.getDimension(e,"border-bottom-width","borderBottomWidth")}static getPaddingLeft(e){return M.getDimension(e,"padding-left","paddingLeft")}static getPaddingRight(e){return M.getDimension(e,"padding-right","paddingRight")}static getPaddingTop(e){return M.getDimension(e,"padding-top","paddingTop")}static getPaddingBottom(e){return M.getDimension(e,"padding-bottom","paddingBottom")}static getMarginLeft(e){return M.getDimension(e,"margin-left","marginLeft")}static getMarginTop(e){return M.getDimension(e,"margin-top","marginTop")}static getMarginRight(e){return M.getDimension(e,"margin-right","marginRight")}static getMarginBottom(e){return M.getDimension(e,"margin-bottom","marginBottom")}}class O{static{this.None=new O(0,0)}constructor(e,t){this.width=e,this.height=t}with(e=this.width,t=this.height){return e!==this.width||t!==this.height?new O(e,t):this}static is(e){return"object"==typeof e&&"number"==typeof e.height&&"number"==typeof e.width}static lift(e){return e instanceof O?e:new O(e.width,e.height)}static equals(e,t){return e===t||!(!e||!t)&&e.width===t.width&&e.height===t.height}}function I(e){let t=e.offsetParent,i=e.offsetTop,s=e.offsetLeft;for(;null!==(e=e.parentNode)&&e!==e.ownerDocument.body&&e!==e.ownerDocument.documentElement;){i-=e.scrollTop;const r=W(e)?null:k(e);r&&(s-="rtl"!==r.direction?e.scrollLeft:-e.scrollLeft),e===t&&(s+=M.getBorderLeftWidth(e),i+=M.getBorderTopWidth(e),i+=e.offsetTop,s+=e.offsetLeft,t=e.offsetParent)}return{left:s,top:i}}function P(e){const t=M.getMarginLeft(e)+M.getMarginRight(e);return e.offsetWidth+t}function x(e){const t=M.getMarginLeft(e)+M.getMarginRight(e);return e.scrollWidth+t}function B(e,t){return Boolean(t?.contains(e))}t.Dimension=O;const N="parentFlowToElementId";function U(e){const t=e.dataset[N];return"string"==typeof t?e.ownerDocument.getElementById(t):null}function F(e,t,i){for(;e&&e.nodeType===e.ELEMENT_NODE;){if(e.classList.contains(t))return e;if(i)if("string"==typeof i){if(e.classList.contains(i))return null}else if(e===i)return null;e=e.parentNode}return null}function W(e){return e&&!!e.host&&!!e.mode}function H(e){for(;e.parentNode;){if(e===e.ownerDocument?.body)return null;e=e.parentNode}return W(e)?e:null}function K(){let e=z().activeElement;for(;e?.shadowRoot;)e=e.shadowRoot.activeElement;return e}function z(){return(0,t.getWindowsCount)()<=1?v.mainWindow.document:Array.from((0,t.getWindows)()).map((({window:e})=>e.document)).find((e=>e.hasFocus()))??v.mainWindow.document}const j=new Map;class ${constructor(){this._currentCssStyle="",this._styleSheet=void 0}setStyle(e){e!==this._currentCssStyle&&(this._currentCssStyle=e,this._styleSheet?this._styleSheet.innerText=e:this._styleSheet=V(v.mainWindow.document.head,(t=>t.innerText=e)))}dispose(){this._styleSheet&&(this._styleSheet.remove(),this._styleSheet=void 0)}}function V(e=v.mainWindow.document.head,i,s){const r=document.createElement("style");if(r.type="text/css",r.media="screen",i?.(r),e.appendChild(r),s&&s.add((0,p.toDisposable)((()=>r.remove()))),e===v.mainWindow.document.head){const e=new Set;j.set(r,e);for(const{window:i,disposables:n}of(0,t.getWindows)()){if(i===v.mainWindow)continue;const t=n.add(G(r,e,i));s?.add(t)}}return r}function G(e,i,s){const r=new p.DisposableStore,n=e.cloneNode(!0);s.document.head.appendChild(n),r.add((0,p.toDisposable)((()=>n.remove())));for(const t of Z(e))n.sheet?.insertRule(t.cssText,n.sheet?.cssRules.length);return r.add(t.sharedMutationObserver.observe(e,r,{childList:!0})((()=>{n.textContent=e.textContent}))),i.add(n),r.add((0,p.toDisposable)((()=>i.delete(n)))),r}function q(e,t=v.mainWindow.document.head){const i=document.createElement(e);return t.appendChild(i),i}t.sharedMutationObserver=new class{constructor(){this.mutationObservers=new Map}observe(e,t,i){let s=this.mutationObservers.get(e);s||(s=new Map,this.mutationObservers.set(e,s));const r=(0,m.hash)(i);let n=s.get(r);if(n)n.users+=1;else{const o=new _.Emitter,a=new MutationObserver((e=>o.fire(e)));a.observe(e,i);const l=n={users:1,observer:a,onDidMutate:o.event};t.add((0,p.toDisposable)((()=>{l.users-=1,0===l.users&&(o.dispose(),a.disconnect(),s?.delete(r),0===s?.size&&this.mutationObservers.delete(e))}))),s.set(r,n)}return n.onDidMutate}};let X=null;function Y(){return X||(X=V()),X}function Z(e){return e?.sheet?.rules?e.sheet.rules:e?.sheet?.cssRules?e.sheet.cssRules:[]}function J(e){return"string"==typeof e.selectorText}function Q(e){return e instanceof HTMLElement||e instanceof(0,t.getWindow)(e).HTMLElement}t.EventType={CLICK:"click",AUXCLICK:"auxclick",DBLCLICK:"dblclick",MOUSE_UP:"mouseup",MOUSE_DOWN:"mousedown",MOUSE_OVER:"mouseover",MOUSE_MOVE:"mousemove",MOUSE_OUT:"mouseout",MOUSE_ENTER:"mouseenter",MOUSE_LEAVE:"mouseleave",MOUSE_WHEEL:"wheel",POINTER_UP:"pointerup",POINTER_DOWN:"pointerdown",POINTER_MOVE:"pointermove",POINTER_LEAVE:"pointerleave",CONTEXT_MENU:"contextmenu",WHEEL:"wheel",KEY_DOWN:"keydown",KEY_PRESS:"keypress",KEY_UP:"keyup",LOAD:"load",BEFORE_UNLOAD:"beforeunload",UNLOAD:"unload",PAGE_SHOW:"pageshow",PAGE_HIDE:"pagehide",PASTE:"paste",ABORT:"abort",ERROR:"error",RESIZE:"resize",SCROLL:"scroll",FULLSCREEN_CHANGE:"fullscreenchange",WK_FULLSCREEN_CHANGE:"webkitfullscreenchange",SELECT:"select",CHANGE:"change",SUBMIT:"submit",RESET:"reset",FOCUS:"focus",FOCUS_IN:"focusin",FOCUS_OUT:"focusout",BLUR:"blur",INPUT:"input",STORAGE:"storage",DRAG_START:"dragstart",DRAG:"drag",DRAG_ENTER:"dragenter",DRAG_LEAVE:"dragleave",DRAG_OVER:"dragover",DROP:"drop",DRAG_END:"dragend",ANIMATION_START:a.isWebKit?"webkitAnimationStart":"animationstart",ANIMATION_END:a.isWebKit?"webkitAnimationEnd":"animationend",ANIMATION_ITERATION:a.isWebKit?"webkitAnimationIteration":"animationiteration"},t.EventHelper={stop:(e,t)=>(e.preventDefault(),t&&e.stopPropagation(),e)};class ee extends p.Disposable{static hasFocusWithin(e){if(Q(e)){const t=H(e);return B(t?t.activeElement:e.ownerDocument.activeElement,e)}{const t=e;return B(t.document.activeElement,t.document)}}constructor(e){super(),this._onDidFocus=this._register(new _.Emitter),this.onDidFocus=this._onDidFocus.event,this._onDidBlur=this._register(new _.Emitter),this.onDidBlur=this._onDidBlur.event;let i=ee.hasFocusWithin(e),s=!1;const r=()=>{s=!1,i||(i=!0,this._onDidFocus.fire())},n=()=>{i&&(s=!0,(Q(e)?(0,t.getWindow)(e):e).setTimeout((()=>{s&&(s=!1,i=!1,this._onDidBlur.fire())}),0))};this._refreshStateHandler=()=>{ee.hasFocusWithin(e)!==i&&(i?n():r())},this._register(C(e,t.EventType.FOCUS,r,!0)),this._register(C(e,t.EventType.BLUR,n,!0)),Q(e)&&(this._register(C(e,t.EventType.FOCUS_IN,(()=>this._refreshStateHandler()))),this._register(C(e,t.EventType.FOCUS_OUT,(()=>this._refreshStateHandler()))))}refreshState(){this._refreshStateHandler()}}function te(e,...t){if(e.append(...t),1===t.length&&"string"!=typeof t[0])return t[0]}const ie=/([\w\-]+)?(#([\w\-]+))?((\.([\w\-]+))*)/;var se;function re(e,t,i,...s){const r=ie.exec(t);if(!r)throw new Error("Bad use of emmet");const n=r[1]||"div";let o;return o=e!==se.HTML?document.createElementNS(e,n):document.createElement(n),r[3]&&(o.id=r[3]),r[4]&&(o.className=r[4].replace(/\./g," ").trim()),i&&Object.entries(i).forEach((([e,t])=>{void 0!==t&&(/^on\w+$/.test(e)?o[e]=t:"selected"===e?t&&o.setAttribute(e,"true"):o.setAttribute(e,t))})),o.append(...s),o}function ne(e,t,...i){return re(se.HTML,e,t,...i)}function oe(...e){for(const t of e)t.style.display="",t.removeAttribute("aria-hidden")}function ae(...e){for(const t of e)t.style.display="none",t.setAttribute("aria-hidden","true")}!function(e){e.HTML="http://www.w3.org/1999/xhtml",e.SVG="http://www.w3.org/2000/svg"}(se||(t.Namespace=se={})),ne.SVG=function(e,t,...i){return re(se.SVG,e,t,...i)};const le=780,he=640;var ce;!function(e){e[e.DOCUMENT=1]="DOCUMENT",e[e.BROWSER=2]="BROWSER"}(ce||(t.DetectedFullscreenMode=ce={}));class de extends _.Emitter{constructor(){super(),this._subscriptions=new p.DisposableStore,this._keyStatus={altKey:!1,shiftKey:!1,ctrlKey:!1,metaKey:!1},this._subscriptions.add(_.Event.runAndSubscribe(t.onDidRegisterWindow,(({window:e,disposables:t})=>this.registerListeners(e,t)),{window:v.mainWindow,disposables:this._subscriptions}))}registerListeners(e,t){t.add(C(e,"keydown",(e=>{if(e.defaultPrevented)return;const t=new h.StandardKeyboardEvent(e);if(t.keyCode!==f.KeyCode.Alt||!e.repeat){if(e.altKey&&!this._keyStatus.altKey)this._keyStatus.lastKeyPressed="alt";else if(e.ctrlKey&&!this._keyStatus.ctrlKey)this._keyStatus.lastKeyPressed="ctrl";else if(e.metaKey&&!this._keyStatus.metaKey)this._keyStatus.lastKeyPressed="meta";else if(e.shiftKey&&!this._keyStatus.shiftKey)this._keyStatus.lastKeyPressed="shift";else{if(t.keyCode===f.KeyCode.Alt)return;this._keyStatus.lastKeyPressed=void 0}this._keyStatus.altKey=e.altKey,this._keyStatus.ctrlKey=e.ctrlKey,this._keyStatus.metaKey=e.metaKey,this._keyStatus.shiftKey=e.shiftKey,this._keyStatus.lastKeyPressed&&(this._keyStatus.event=e,this.fire(this._keyStatus))}}),!0)),t.add(C(e,"keyup",(e=>{e.defaultPrevented||(!e.altKey&&this._keyStatus.altKey?this._keyStatus.lastKeyReleased="alt":!e.ctrlKey&&this._keyStatus.ctrlKey?this._keyStatus.lastKeyReleased="ctrl":!e.metaKey&&this._keyStatus.metaKey?this._keyStatus.lastKeyReleased="meta":!e.shiftKey&&this._keyStatus.shiftKey?this._keyStatus.lastKeyReleased="shift":this._keyStatus.lastKeyReleased=void 0,this._keyStatus.lastKeyPressed!==this._keyStatus.lastKeyReleased&&(this._keyStatus.lastKeyPressed=void 0),this._keyStatus.altKey=e.altKey,this._keyStatus.ctrlKey=e.ctrlKey,this._keyStatus.metaKey=e.metaKey,this._keyStatus.shiftKey=e.shiftKey,this._keyStatus.lastKeyReleased&&(this._keyStatus.event=e,this.fire(this._keyStatus)))}),!0)),t.add(C(e.document.body,"mousedown",(()=>{this._keyStatus.lastKeyPressed=void 0}),!0)),t.add(C(e.document.body,"mouseup",(()=>{this._keyStatus.lastKeyPressed=void 0}),!0)),t.add(C(e.document.body,"mousemove",(e=>{e.buttons&&(this._keyStatus.lastKeyPressed=void 0)}),!0)),t.add(C(e,"blur",(()=>{this.resetKeyStatus()})))}get keyStatus(){return this._keyStatus}get isModifierPressed(){return this._keyStatus.altKey||this._keyStatus.ctrlKey||this._keyStatus.metaKey||this._keyStatus.shiftKey}resetKeyStatus(){this.doResetKeyStatus(),this.fire(this._keyStatus)}doResetKeyStatus(){this._keyStatus={altKey:!1,shiftKey:!1,ctrlKey:!1,metaKey:!1}}static getInstance(){return de.instance||(de.instance=new de),de.instance}dispose(){super.dispose(),this._subscriptions.dispose()}}t.ModifierKeyEmitter=de;class ue extends p.Disposable{constructor(e,t){super(),this.element=e,this.callbacks=t,this.counter=0,this.dragStartTime=0,this.registerListeners()}registerListeners(){this.callbacks.onDragStart&&this._register(C(this.element,t.EventType.DRAG_START,(e=>{this.callbacks.onDragStart?.(e)}))),this.callbacks.onDrag&&this._register(C(this.element,t.EventType.DRAG,(e=>{this.callbacks.onDrag?.(e)}))),this._register(C(this.element,t.EventType.DRAG_ENTER,(e=>{this.counter++,this.dragStartTime=e.timeStamp,this.callbacks.onDragEnter?.(e)}))),this._register(C(this.element,t.EventType.DRAG_OVER,(e=>{e.preventDefault(),this.callbacks.onDragOver?.(e,e.timeStamp-this.dragStartTime)}))),this._register(C(this.element,t.EventType.DRAG_LEAVE,(e=>{this.counter--,0===this.counter&&(this.dragStartTime=0,this.callbacks.onDragLeave?.(e))}))),this._register(C(this.element,t.EventType.DRAG_END,(e=>{this.counter=0,this.dragStartTime=0,this.callbacks.onDragEnd?.(e)}))),this._register(C(this.element,t.EventType.DROP,(e=>{this.counter=0,this.dragStartTime=0,this.callbacks.onDrop?.(e)})))}}t.DragAndDropObserver=ue;const _e=/(?[\w\-]+)?(?:#(?[\w\-]+))?(?(?:\.(?:[\w\-]+))*)(?:@(?(?:[\w\_])+))?/;function fe(e){return e.replace(/([a-z])([A-Z])/g,"$1-$2").toLowerCase()}function pe(e,t,i){for(const{name:s,value:r}of e.attributes)i&&!i.includes(s)||t.setAttribute(s,r)}function ge(e,t,i){const s=e.getAttribute(i);s?t.setAttribute(i,s):t.removeAttribute(i)}t.SafeTriangle=class{constructor(e,t,i){this.originX=e,this.originY=t,this.triangles=[];const{top:s,left:r,right:n,bottom:o}=i.getBoundingClientRect(),a=this.triangles;let l=0;a[l++]=r,a[l++]=s,a[l++]=n,a[l++]=s,a[l++]=r,a[l++]=s,a[l++]=r,a[l++]=o,a[l++]=n,a[l++]=s,a[l++]=n,a[l++]=o,a[l++]=r,a[l++]=o,a[l++]=n,a[l++]=o}contains(e,t){const{triangles:i,originX:s,originY:r}=this;for(let n=0;n<4;n++)if((0,S.isPointWithinTriangle)(e,t,s,r,i[2*n],i[2*n+1],i[2*n+2],i[2*n+3]))return!0;return!1}}},9675:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.FastDomNode=void 0,t.createFastDomNode=function(e){return new i(e)};class i{constructor(e){this.domNode=e,this._maxWidth="",this._width="",this._height="",this._top="",this._left="",this._bottom="",this._right="",this._paddingTop="",this._paddingLeft="",this._paddingBottom="",this._paddingRight="",this._fontFamily="",this._fontWeight="",this._fontSize="",this._fontStyle="",this._fontFeatureSettings="",this._fontVariationSettings="",this._textDecoration="",this._lineHeight="",this._letterSpacing="",this._className="",this._display="",this._position="",this._visibility="",this._color="",this._backgroundColor="",this._layerHint=!1,this._contain="none",this._boxShadow=""}setMaxWidth(e){const t=s(e);this._maxWidth!==t&&(this._maxWidth=t,this.domNode.style.maxWidth=this._maxWidth)}setWidth(e){const t=s(e);this._width!==t&&(this._width=t,this.domNode.style.width=this._width)}setHeight(e){const t=s(e);this._height!==t&&(this._height=t,this.domNode.style.height=this._height)}setTop(e){const t=s(e);this._top!==t&&(this._top=t,this.domNode.style.top=this._top)}setLeft(e){const t=s(e);this._left!==t&&(this._left=t,this.domNode.style.left=this._left)}setBottom(e){const t=s(e);this._bottom!==t&&(this._bottom=t,this.domNode.style.bottom=this._bottom)}setRight(e){const t=s(e);this._right!==t&&(this._right=t,this.domNode.style.right=this._right)}setPaddingTop(e){const t=s(e);this._paddingTop!==t&&(this._paddingTop=t,this.domNode.style.paddingTop=this._paddingTop)}setPaddingLeft(e){const t=s(e);this._paddingLeft!==t&&(this._paddingLeft=t,this.domNode.style.paddingLeft=this._paddingLeft)}setPaddingBottom(e){const t=s(e);this._paddingBottom!==t&&(this._paddingBottom=t,this.domNode.style.paddingBottom=this._paddingBottom)}setPaddingRight(e){const t=s(e);this._paddingRight!==t&&(this._paddingRight=t,this.domNode.style.paddingRight=this._paddingRight)}setFontFamily(e){this._fontFamily!==e&&(this._fontFamily=e,this.domNode.style.fontFamily=this._fontFamily)}setFontWeight(e){this._fontWeight!==e&&(this._fontWeight=e,this.domNode.style.fontWeight=this._fontWeight)}setFontSize(e){const t=s(e);this._fontSize!==t&&(this._fontSize=t,this.domNode.style.fontSize=this._fontSize)}setFontStyle(e){this._fontStyle!==e&&(this._fontStyle=e,this.domNode.style.fontStyle=this._fontStyle)}setFontFeatureSettings(e){this._fontFeatureSettings!==e&&(this._fontFeatureSettings=e,this.domNode.style.fontFeatureSettings=this._fontFeatureSettings)}setFontVariationSettings(e){this._fontVariationSettings!==e&&(this._fontVariationSettings=e,this.domNode.style.fontVariationSettings=this._fontVariationSettings)}setTextDecoration(e){this._textDecoration!==e&&(this._textDecoration=e,this.domNode.style.textDecoration=this._textDecoration)}setLineHeight(e){const t=s(e);this._lineHeight!==t&&(this._lineHeight=t,this.domNode.style.lineHeight=this._lineHeight)}setLetterSpacing(e){const t=s(e);this._letterSpacing!==t&&(this._letterSpacing=t,this.domNode.style.letterSpacing=this._letterSpacing)}setClassName(e){this._className!==e&&(this._className=e,this.domNode.className=this._className)}toggleClassName(e,t){this.domNode.classList.toggle(e,t),this._className=this.domNode.className}setDisplay(e){this._display!==e&&(this._display=e,this.domNode.style.display=this._display)}setPosition(e){this._position!==e&&(this._position=e,this.domNode.style.position=this._position)}setVisibility(e){this._visibility!==e&&(this._visibility=e,this.domNode.style.visibility=this._visibility)}setColor(e){this._color!==e&&(this._color=e,this.domNode.style.color=this._color)}setBackgroundColor(e){this._backgroundColor!==e&&(this._backgroundColor=e,this.domNode.style.backgroundColor=this._backgroundColor)}setLayerHinting(e){this._layerHint!==e&&(this._layerHint=e,this.domNode.style.transform=this._layerHint?"translate3d(0px, 0px, 0px)":"")}setBoxShadow(e){this._boxShadow!==e&&(this._boxShadow=e,this.domNode.style.boxShadow=e)}setContain(e){this._contain!==e&&(this._contain=e,this.domNode.style.contain=this._contain)}setAttribute(e,t){this.domNode.setAttribute(e,t)}removeAttribute(e){this.domNode.removeAttribute(e)}appendChild(e){this.domNode.appendChild(e.domNode)}removeChild(e){this.domNode.removeChild(e.domNode)}}function s(e){return"number"==typeof e?`${e}px`:e}t.FastDomNode=i},8328:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.GlobalPointerMoveMonitor=void 0;const o=n(i(7093)),a=i(7150);t.GlobalPointerMoveMonitor=class{constructor(){this._hooks=new a.DisposableStore,this._pointerMoveCallback=null,this._onStopCallback=null}dispose(){this.stopMonitoring(!1),this._hooks.dispose()}stopMonitoring(e,t){if(!this.isMonitoring())return;this._hooks.clear(),this._pointerMoveCallback=null;const i=this._onStopCallback;this._onStopCallback=null,e&&i&&i(t)}isMonitoring(){return!!this._pointerMoveCallback}startMonitoring(e,t,i,s,r){this.isMonitoring()&&this.stopMonitoring(!1),this._pointerMoveCallback=s,this._onStopCallback=r;let n=e;try{e.setPointerCapture(t),this._hooks.add((0,a.toDisposable)((()=>{try{e.releasePointerCapture(t)}catch(e){}})))}catch(t){n=o.getWindow(e)}this._hooks.add(o.addDisposableListener(n,o.EventType.POINTER_MOVE,(e=>{e.buttons===i?(e.preventDefault(),this._pointerMoveCallback(e)):this.stopMonitoring(!0)}))),this._hooks.add(o.addDisposableListener(n,o.EventType.POINTER_UP,(e=>this.stopMonitoring(!0))))}}},6609:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.IframeUtils=void 0,t.parentOriginHash=async function(e,t){if(!crypto.subtle)throw new Error("'crypto.subtle' is not available so webviews will not work. This is likely because the editor is not running in a secure context (https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts).");const i=JSON.stringify({parentOrigin:e,salt:t}),s=(new TextEncoder).encode(i);return function(e){const t=Array.from(new Uint8Array(e)).map((e=>e.toString(16).padStart(2,"0"))).join("");return BigInt(`0x${t}`).toString(32).padStart(52,"0")}(await crypto.subtle.digest("sha-256",s))};const i=new WeakMap;function s(e){if(!e.parent||e.parent===e)return null;try{const t=e.location,i=e.parent.location;if("null"!==t.origin&&"null"!==i.origin&&t.origin!==i.origin)return null}catch(e){return null}return e.parent}t.IframeUtils=class{static getSameOriginWindowChain(e){let t=i.get(e);if(!t){t=[],i.set(e,t);let r,n=e;do{r=s(n),r?t.push({window:new WeakRef(n),iframeElement:n.frameElement||null}):t.push({window:new WeakRef(n),iframeElement:null}),n=r}while(n)}return t.slice(0)}static getPositionOfChildWindowRelativeToAncestorWindow(e,t){if(!t||e===t)return{top:0,left:0};let i=0,s=0;const r=this.getSameOriginWindowChain(e);for(const e of r){const r=e.window.deref();if(i+=r?.scrollY??0,s+=r?.scrollX??0,r===t)break;if(!e.iframeElement)break;const n=e.iframeElement.getBoundingClientRect();i+=n.top,s+=n.left}return{top:i,left:s}}}},5394:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.StandardKeyboardEvent=void 0,t.printKeyboardEvent=function(e){const t=[];return e.ctrlKey&&t.push("ctrl"),e.shiftKey&&t.push("shift"),e.altKey&&t.push("alt"),e.metaKey&&t.push("meta"),`modifiers: [${t.join(",")}], code: ${e.code}, keyCode: ${e.keyCode}, key: ${e.key}`},t.printStandardKeyboardEvent=function(e){const t=[];return e.ctrlKey&&t.push("ctrl"),e.shiftKey&&t.push("shift"),e.altKey&&t.push("alt"),e.metaKey&&t.push("meta"),`modifiers: [${t.join(",")}], code: ${e.code}, keyCode: ${e.keyCode} ('${a.KeyCodeUtils.toString(e.keyCode)}')`};const o=n(i(4333)),a=i(7883),l=i(2811),h=n(i(8163)),c=h.isMacintosh?a.KeyMod.WinCtrl:a.KeyMod.CtrlCmd,d=a.KeyMod.Alt,u=a.KeyMod.Shift,_=h.isMacintosh?a.KeyMod.CtrlCmd:a.KeyMod.WinCtrl;t.StandardKeyboardEvent=class{constructor(e){this._standardKeyboardEventBrand=!0;const t=e;this.browserEvent=t,this.target=t.target,this.ctrlKey=t.ctrlKey,this.shiftKey=t.shiftKey,this.altKey=t.altKey,this.metaKey=t.metaKey,this.altGraphKey=t.getModifierState?.("AltGraph"),this.keyCode=function(e){if(e.charCode){const t=String.fromCharCode(e.charCode).toUpperCase();return a.KeyCodeUtils.fromString(t)}const t=e.keyCode;if(3===t)return a.KeyCode.PauseBreak;if(o.isFirefox)switch(t){case 59:return a.KeyCode.Semicolon;case 60:if(h.isLinux)return a.KeyCode.IntlBackslash;break;case 61:return a.KeyCode.Equal;case 107:return a.KeyCode.NumpadAdd;case 109:return a.KeyCode.NumpadSubtract;case 173:return a.KeyCode.Minus;case 224:if(h.isMacintosh)return a.KeyCode.Meta}else if(o.isWebKit){if(h.isMacintosh&&93===t)return a.KeyCode.Meta;if(!h.isMacintosh&&92===t)return a.KeyCode.Meta}return a.EVENT_KEY_CODE_MAP[t]||a.KeyCode.Unknown}(t),this.code=t.code,this.ctrlKey=this.ctrlKey||this.keyCode===a.KeyCode.Ctrl,this.altKey=this.altKey||this.keyCode===a.KeyCode.Alt,this.shiftKey=this.shiftKey||this.keyCode===a.KeyCode.Shift,this.metaKey=this.metaKey||this.keyCode===a.KeyCode.Meta,this._asKeybinding=this._computeKeybinding(),this._asKeyCodeChord=this._computeKeyCodeChord()}preventDefault(){this.browserEvent&&this.browserEvent.preventDefault&&this.browserEvent.preventDefault()}stopPropagation(){this.browserEvent&&this.browserEvent.stopPropagation&&this.browserEvent.stopPropagation()}toKeyCodeChord(){return this._asKeyCodeChord}equals(e){return this._asKeybinding===e}_computeKeybinding(){let e=a.KeyCode.Unknown;this.keyCode!==a.KeyCode.Ctrl&&this.keyCode!==a.KeyCode.Shift&&this.keyCode!==a.KeyCode.Alt&&this.keyCode!==a.KeyCode.Meta&&(e=this.keyCode);let t=0;return this.ctrlKey&&(t|=c),this.altKey&&(t|=d),this.shiftKey&&(t|=u),this.metaKey&&(t|=_),t|=e,t}_computeKeyCodeChord(){let e=a.KeyCode.Unknown;return this.keyCode!==a.KeyCode.Ctrl&&this.keyCode!==a.KeyCode.Shift&&this.keyCode!==a.KeyCode.Alt&&this.keyCode!==a.KeyCode.Meta&&(e=this.keyCode),new l.KeyCodeChord(this.ctrlKey,this.shiftKey,this.altKey,this.metaKey,e)}}},5964:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.StandardWheelEvent=t.DragMouseEvent=t.StandardMouseEvent=void 0;const o=n(i(4333)),a=i(6609),l=n(i(8163));class h{constructor(e,t){this.timestamp=Date.now(),this.browserEvent=t,this.leftButton=0===t.button,this.middleButton=1===t.button,this.rightButton=2===t.button,this.buttons=t.buttons,this.target=t.target,this.detail=t.detail||1,"dblclick"===t.type&&(this.detail=2),this.ctrlKey=t.ctrlKey,this.shiftKey=t.shiftKey,this.altKey=t.altKey,this.metaKey=t.metaKey,"number"==typeof t.pageX?(this.posx=t.pageX,this.posy=t.pageY):(this.posx=t.clientX+this.target.ownerDocument.body.scrollLeft+this.target.ownerDocument.documentElement.scrollLeft,this.posy=t.clientY+this.target.ownerDocument.body.scrollTop+this.target.ownerDocument.documentElement.scrollTop);const i=a.IframeUtils.getPositionOfChildWindowRelativeToAncestorWindow(e,t.view);this.posx-=i.left,this.posy-=i.top}preventDefault(){this.browserEvent.preventDefault()}stopPropagation(){this.browserEvent.stopPropagation()}}t.StandardMouseEvent=h,t.DragMouseEvent=class extends h{constructor(e,t){super(e,t),this.dataTransfer=t.dataTransfer}},t.StandardWheelEvent=class{constructor(e,t=0,i=0){this.browserEvent=e||null,this.target=e?e.target||e.targetNode||e.srcElement:null,this.deltaY=i,this.deltaX=t;let s=!1;if(o.isChrome){const e=navigator.userAgent.match(/Chrome\/(\d+)/);s=(e?parseInt(e[1]):123)<=122}if(e){const t=e,i=e,r=e.view?.devicePixelRatio||1;if(void 0!==t.wheelDeltaY)this.deltaY=s?t.wheelDeltaY/(120*r):t.wheelDeltaY/120;else if(void 0!==i.VERTICAL_AXIS&&i.axis===i.VERTICAL_AXIS)this.deltaY=-i.detail/3;else if("wheel"===e.type){const t=e;t.deltaMode===t.DOM_DELTA_LINE?o.isFirefox&&!l.isMacintosh?this.deltaY=-e.deltaY/3:this.deltaY=-e.deltaY:this.deltaY=-e.deltaY/40}if(void 0!==t.wheelDeltaX)o.isSafari&&l.isWindows?this.deltaX=-t.wheelDeltaX/120:this.deltaX=s?t.wheelDeltaX/(120*r):t.wheelDeltaX/120;else if(void 0!==i.HORIZONTAL_AXIS&&i.axis===i.HORIZONTAL_AXIS)this.deltaX=-e.detail/3;else if("wheel"===e.type){const t=e;t.deltaMode===t.DOM_DELTA_LINE?o.isFirefox&&!l.isMacintosh?this.deltaX=-e.deltaX/3:this.deltaX=-e.deltaX:this.deltaX=-e.deltaX/40}0===this.deltaY&&0===this.deltaX&&e.wheelDelta&&(this.deltaY=s?e.wheelDelta/(120*r):e.wheelDelta/120)}}preventDefault(){this.browserEvent?.preventDefault()}stopPropagation(){this.browserEvent?.stopPropagation()}}},8594:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},o=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.Gesture=t.EventType=void 0;const a=o(i(7093)),l=i(4693),h=o(i(3058)),c=i(4838),d=i(802),u=i(7150),_=i(6317);var f;!function(e){e.Tap="-xterm-gesturetap",e.Change="-xterm-gesturechange",e.Start="-xterm-gesturestart",e.End="-xterm-gesturesend",e.Contextmenu="-xterm-gesturecontextmenu"}(f||(t.EventType=f={}));class p extends u.Disposable{static{this.SCROLL_FRICTION=-.005}static{this.HOLD_DELAY=700}static{this.CLEAR_TAP_COUNT_TIME=400}constructor(){super(),this.dispatched=!1,this.targets=new _.LinkedList,this.ignoreTargets=new _.LinkedList,this.activeTouches={},this.handle=null,this._lastSetTapCountTime=0,this._register(d.Event.runAndSubscribe(a.onDidRegisterWindow,(({window:e,disposables:t})=>{t.add(a.addDisposableListener(e.document,"touchstart",(e=>this.onTouchStart(e)),{passive:!1})),t.add(a.addDisposableListener(e.document,"touchend",(t=>this.onTouchEnd(e,t)))),t.add(a.addDisposableListener(e.document,"touchmove",(e=>this.onTouchMove(e)),{passive:!1}))}),{window:l.mainWindow,disposables:this._store}))}static addTarget(e){if(!p.isTouchDevice())return u.Disposable.None;p.INSTANCE||(p.INSTANCE=(0,u.markAsSingleton)(new p));const t=p.INSTANCE.targets.push(e);return(0,u.toDisposable)(t)}static ignoreTarget(e){if(!p.isTouchDevice())return u.Disposable.None;p.INSTANCE||(p.INSTANCE=(0,u.markAsSingleton)(new p));const t=p.INSTANCE.ignoreTargets.push(e);return(0,u.toDisposable)(t)}static isTouchDevice(){return"ontouchstart"in l.mainWindow||navigator.maxTouchPoints>0}dispose(){this.handle&&(this.handle.dispose(),this.handle=null),super.dispose()}onTouchStart(e){const t=Date.now();this.handle&&(this.handle.dispose(),this.handle=null);for(let i=0,s=e.targetTouches.length;i=p.HOLD_DELAY&&Math.abs(o.initialPageX-h.tail(o.rollingPageX))<30&&Math.abs(o.initialPageY-h.tail(o.rollingPageY))<30){const e=this.newGestureEvent(f.Contextmenu,o.initialTarget);e.pageX=h.tail(o.rollingPageX),e.pageY=h.tail(o.rollingPageY),this.dispatchEvent(e)}else if(1===s){const t=h.tail(o.rollingPageX),s=h.tail(o.rollingPageY),r=h.tail(o.rollingTimestamps)-o.rollingTimestamps[0],n=t-o.rollingPageX[0],a=s-o.rollingPageY[0],l=[...this.targets].filter((e=>o.initialTarget instanceof Node&&e.contains(o.initialTarget)));this.inertia(e,l,i,Math.abs(n)/r,n>0?1:-1,t,Math.abs(a)/r,a>0?1:-1,s)}this.dispatchEvent(this.newGestureEvent(f.End,o.initialTarget)),delete this.activeTouches[n.identifier]}this.dispatched&&(t.preventDefault(),t.stopPropagation(),this.dispatched=!1)}newGestureEvent(e,t){const i=document.createEvent("CustomEvent");return i.initEvent(e,!1,!0),i.initialTarget=t,i.tapCount=0,i}dispatchEvent(e){if(e.type===f.Tap){const t=(new Date).getTime();let i=0;i=t-this._lastSetTapCountTime>p.CLEAR_TAP_COUNT_TIME?1:2,this._lastSetTapCountTime=t,e.tapCount=i}else e.type!==f.Change&&e.type!==f.Contextmenu||(this._lastSetTapCountTime=0);if(e.initialTarget instanceof Node){for(const t of this.ignoreTargets)if(t.contains(e.initialTarget))return;const t=[];for(const i of this.targets)if(i.contains(e.initialTarget)){let s=0,r=e.initialTarget;for(;r&&r!==i;)s++,r=r.parentElement;t.push([s,i])}t.sort(((e,t)=>e[0]-t[0]));for(const[i,s]of t)s.dispatchEvent(e),this.dispatched=!0}}inertia(e,t,i,s,r,n,o,l,h){this.handle=a.scheduleAtNextAnimationFrame(e,(()=>{const a=Date.now(),c=a-i;let d=0,u=0,_=!0;s+=p.SCROLL_FRICTION*c,o+=p.SCROLL_FRICTION*c,s>0&&(_=!1,d=r*s*c),o>0&&(_=!1,u=l*o*c);const g=this.newGestureEvent(f.Change);g.translationX=d,g.translationY=u,t.forEach((e=>e.dispatchEvent(g))),_||this.inertia(e,t,a,s,r,n+d,o,l,h+u)}))}onTouchMove(e){const t=Date.now();for(let i=0,s=e.changedTouches.length;i3&&(r.rollingPageX.shift(),r.rollingPageY.shift(),r.rollingTimestamps.shift()),r.rollingPageX.push(s.pageX),r.rollingPageY.push(s.pageY),r.rollingTimestamps.push(t)}this.dispatched&&(e.preventDefault(),e.stopPropagation(),this.dispatched=!1)}}t.Gesture=p,n([c.memoize],p,"isTouchDevice",null)},8801:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.AbstractScrollbar=void 0;const o=n(i(7093)),a=i(9675),l=i(8328),h=i(8974),c=i(79),d=i(8286),u=n(i(8163));class _ extends d.Widget{constructor(e){super(),this._lazyRender=e.lazyRender,this._host=e.host,this._scrollable=e.scrollable,this._scrollByPage=e.scrollByPage,this._scrollbarState=e.scrollbarState,this._visibilityController=this._register(new c.ScrollbarVisibilityController(e.visibility,"visible scrollbar "+e.extraScrollbarClassName,"invisible scrollbar "+e.extraScrollbarClassName)),this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._pointerMoveMonitor=this._register(new l.GlobalPointerMoveMonitor),this._shouldRender=!0,this.domNode=(0,a.createFastDomNode)(document.createElement("div")),this.domNode.setAttribute("role","presentation"),this.domNode.setAttribute("aria-hidden","true"),this._visibilityController.setDomNode(this.domNode),this.domNode.setPosition("absolute"),this._register(o.addDisposableListener(this.domNode.domNode,o.EventType.POINTER_DOWN,(e=>this._domNodePointerDown(e))))}_createArrow(e){const t=this._register(new h.ScrollbarArrow(e));this.domNode.domNode.appendChild(t.bgDomNode),this.domNode.domNode.appendChild(t.domNode)}_createSlider(e,t,i,s){this.slider=(0,a.createFastDomNode)(document.createElement("div")),this.slider.setClassName("slider"),this.slider.setPosition("absolute"),this.slider.setTop(e),this.slider.setLeft(t),"number"==typeof i&&this.slider.setWidth(i),"number"==typeof s&&this.slider.setHeight(s),this.slider.setLayerHinting(!0),this.slider.setContain("strict"),this.domNode.domNode.appendChild(this.slider.domNode),this._register(o.addDisposableListener(this.slider.domNode,o.EventType.POINTER_DOWN,(e=>{0===e.button&&(e.preventDefault(),this._sliderPointerDown(e))}))),this.onclick(this.slider.domNode,(e=>{e.leftButton&&e.stopPropagation()}))}_onElementSize(e){return this._scrollbarState.setVisibleSize(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}_onElementScrollSize(e){return this._scrollbarState.setScrollSize(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}_onElementScrollPosition(e){return this._scrollbarState.setScrollPosition(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}beginReveal(){this._visibilityController.setShouldBeVisible(!0)}beginHide(){this._visibilityController.setShouldBeVisible(!1)}render(){this._shouldRender&&(this._shouldRender=!1,this._renderDomNode(this._scrollbarState.getRectangleLargeSize(),this._scrollbarState.getRectangleSmallSize()),this._updateSlider(this._scrollbarState.getSliderSize(),this._scrollbarState.getArrowSize()+this._scrollbarState.getSliderPosition()))}_domNodePointerDown(e){e.target===this.domNode.domNode&&this._onPointerDown(e)}delegatePointerDown(e){const t=this.domNode.domNode.getClientRects()[0].top,i=t+this._scrollbarState.getSliderPosition(),s=t+this._scrollbarState.getSliderPosition()+this._scrollbarState.getSliderSize(),r=this._sliderPointerPosition(e);i<=r&&r<=s?0===e.button&&(e.preventDefault(),this._sliderPointerDown(e)):this._onPointerDown(e)}_onPointerDown(e){let t,i;if(e.target===this.domNode.domNode&&"number"==typeof e.offsetX&&"number"==typeof e.offsetY)t=e.offsetX,i=e.offsetY;else{const s=o.getDomNodePagePosition(this.domNode.domNode);t=e.pageX-s.left,i=e.pageY-s.top}const s=this._pointerDownRelativePosition(t,i);this._setDesiredScrollPositionNow(this._scrollByPage?this._scrollbarState.getDesiredScrollPositionFromOffsetPaged(s):this._scrollbarState.getDesiredScrollPositionFromOffset(s)),0===e.button&&(e.preventDefault(),this._sliderPointerDown(e))}_sliderPointerDown(e){if(!(e.target&&e.target instanceof Element))return;const t=this._sliderPointerPosition(e),i=this._sliderOrthogonalPointerPosition(e),s=this._scrollbarState.clone();this.slider.toggleClassName("active",!0),this._pointerMoveMonitor.startMonitoring(e.target,e.pointerId,e.buttons,(e=>{const r=this._sliderOrthogonalPointerPosition(e),n=Math.abs(r-i);if(u.isWindows&&n>140)return void this._setDesiredScrollPositionNow(s.getScrollPosition());const o=this._sliderPointerPosition(e)-t;this._setDesiredScrollPositionNow(s.getDesiredScrollPositionFromDelta(o))}),(()=>{this.slider.toggleClassName("active",!1),this._host.onDragEnd()})),this._host.onDragStart()}_setDesiredScrollPositionNow(e){const t={};this.writeScrollPosition(t,e),this._scrollable.setScrollPositionNow(t)}updateScrollbarSize(e){this._updateScrollbarSize(e),this._scrollbarState.setScrollbarSize(e),this._shouldRender=!0,this._lazyRender||this.render()}isNeeded(){return this._scrollbarState.isNeeded()}}t.AbstractScrollbar=_},151:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.HorizontalScrollbar=void 0;const s=i(8801),r=i(8245),n=i(9881);class o extends s.AbstractScrollbar{constructor(e,t,i){const s=e.getScrollDimensions(),o=e.getCurrentScrollPosition();if(super({lazyRender:t.lazyRender,host:i,scrollbarState:new r.ScrollbarState(t.horizontalHasArrows?t.arrowSize:0,t.horizontal===n.ScrollbarVisibility.Hidden?0:t.horizontalScrollbarSize,t.vertical===n.ScrollbarVisibility.Hidden?0:t.verticalScrollbarSize,s.width,s.scrollWidth,o.scrollLeft),visibility:t.horizontal,extraScrollbarClassName:"horizontal",scrollable:e,scrollByPage:t.scrollByPage}),t.horizontalHasArrows)throw new Error("horizontalHasArrows is not supported in xterm.js");this._createSlider(Math.floor((t.horizontalScrollbarSize-t.horizontalSliderSize)/2),0,void 0,t.horizontalSliderSize)}_updateSlider(e,t){this.slider.setWidth(e),this.slider.setLeft(t)}_renderDomNode(e,t){this.domNode.setWidth(e),this.domNode.setHeight(t),this.domNode.setLeft(0),this.domNode.setBottom(0)}onDidScroll(e){return this._shouldRender=this._onElementScrollSize(e.scrollWidth)||this._shouldRender,this._shouldRender=this._onElementScrollPosition(e.scrollLeft)||this._shouldRender,this._shouldRender=this._onElementSize(e.width)||this._shouldRender,this._shouldRender}_pointerDownRelativePosition(e,t){return e}_sliderPointerPosition(e){return e.pageX}_sliderOrthogonalPointerPosition(e){return e.pageY}_updateScrollbarSize(e){this.slider.setHeight(e)}writeScrollPosition(e,t){e.scrollLeft=t}updateOptions(e){this.updateScrollbarSize(e.horizontal===n.ScrollbarVisibility.Hidden?0:e.horizontalScrollbarSize),this._scrollbarState.setOppositeScrollbarSize(e.vertical===n.ScrollbarVisibility.Hidden?0:e.verticalScrollbarSize),this._visibilityController.setVisibility(e.horizontal),this._scrollByPage=e.scrollByPage}}t.HorizontalScrollbar=o},8234:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.DomScrollableElement=t.SmoothScrollableElement=t.ScrollableElement=t.AbstractScrollableElement=t.MouseWheelClassifier=void 0;const o=i(4333),a=n(i(7093)),l=i(9675),h=i(5964),c=i(151),d=i(5473),u=i(8286),_=i(1758),f=i(802),p=i(7150),g=n(i(8163)),m=i(9881);class v{constructor(e,t,i){this.timestamp=e,this.deltaX=t,this.deltaY=i,this.score=0}}class S{static{this.INSTANCE=new S}constructor(){this._capacity=5,this._memory=[],this._front=-1,this._rear=-1}isPhysicalMouseWheel(){if(-1===this._front&&-1===this._rear)return!1;let e=1,t=0,i=1,s=this._rear;for(;;){const r=s===this._front?e:Math.pow(2,-i);if(e-=r,t+=this._memory[s].score*r,s===this._front)break;s=(this._capacity+s-1)%this._capacity,i++}return t<=.5}acceptStandardWheelEvent(e){if(o.isChrome){const t=a.getWindow(e.browserEvent),i=(0,o.getZoomFactor)(t);this.accept(Date.now(),e.deltaX*i,e.deltaY*i)}else this.accept(Date.now(),e.deltaX,e.deltaY)}accept(e,t,i){let s=null;const r=new v(e,t,i);-1===this._front&&-1===this._rear?(this._memory[0]=r,this._front=0,this._rear=0):(s=this._memory[this._rear],this._rear=(this._rear+1)%this._capacity,this._rear===this._front&&(this._front=(this._front+1)%this._capacity),this._memory[this._rear]=r),r.score=this._computeScore(r,s)}_computeScore(e,t){if(Math.abs(e.deltaX)>0&&Math.abs(e.deltaY)>0)return 1;let i=.5;if(this._isAlmostInt(e.deltaX)&&this._isAlmostInt(e.deltaY)||(i+=.25),t){const s=Math.abs(e.deltaX),r=Math.abs(e.deltaY),n=Math.abs(t.deltaX),o=Math.abs(t.deltaY),a=Math.max(Math.min(s,n),1),l=Math.max(Math.min(r,o),1),h=Math.max(s,n),c=Math.max(r,o);h%a==0&&c%l==0&&(i-=.5)}return Math.min(Math.max(i,0),1)}_isAlmostInt(e){return Math.abs(Math.round(e)-e)<.01}}t.MouseWheelClassifier=S;class b extends u.Widget{get options(){return this._options}constructor(e,t,i){super(),this._onScroll=this._register(new f.Emitter),this.onScroll=this._onScroll.event,this._onWillScroll=this._register(new f.Emitter),this.onWillScroll=this._onWillScroll.event,this._options=function(e){const t={lazyRender:void 0!==e.lazyRender&&e.lazyRender,className:void 0!==e.className?e.className:"",useShadows:void 0===e.useShadows||e.useShadows,handleMouseWheel:void 0===e.handleMouseWheel||e.handleMouseWheel,flipAxes:void 0!==e.flipAxes&&e.flipAxes,consumeMouseWheelIfScrollbarIsNeeded:void 0!==e.consumeMouseWheelIfScrollbarIsNeeded&&e.consumeMouseWheelIfScrollbarIsNeeded,alwaysConsumeMouseWheel:void 0!==e.alwaysConsumeMouseWheel&&e.alwaysConsumeMouseWheel,scrollYToX:void 0!==e.scrollYToX&&e.scrollYToX,mouseWheelScrollSensitivity:void 0!==e.mouseWheelScrollSensitivity?e.mouseWheelScrollSensitivity:1,fastScrollSensitivity:void 0!==e.fastScrollSensitivity?e.fastScrollSensitivity:5,scrollPredominantAxis:void 0===e.scrollPredominantAxis||e.scrollPredominantAxis,mouseWheelSmoothScroll:void 0===e.mouseWheelSmoothScroll||e.mouseWheelSmoothScroll,arrowSize:void 0!==e.arrowSize?e.arrowSize:11,listenOnDomNode:void 0!==e.listenOnDomNode?e.listenOnDomNode:null,horizontal:void 0!==e.horizontal?e.horizontal:m.ScrollbarVisibility.Auto,horizontalScrollbarSize:void 0!==e.horizontalScrollbarSize?e.horizontalScrollbarSize:10,horizontalSliderSize:void 0!==e.horizontalSliderSize?e.horizontalSliderSize:0,horizontalHasArrows:void 0!==e.horizontalHasArrows&&e.horizontalHasArrows,vertical:void 0!==e.vertical?e.vertical:m.ScrollbarVisibility.Auto,verticalScrollbarSize:void 0!==e.verticalScrollbarSize?e.verticalScrollbarSize:10,verticalHasArrows:void 0!==e.verticalHasArrows&&e.verticalHasArrows,verticalSliderSize:void 0!==e.verticalSliderSize?e.verticalSliderSize:0,scrollByPage:void 0!==e.scrollByPage&&e.scrollByPage};return t.horizontalSliderSize=void 0!==e.horizontalSliderSize?e.horizontalSliderSize:t.horizontalScrollbarSize,t.verticalSliderSize=void 0!==e.verticalSliderSize?e.verticalSliderSize:t.verticalScrollbarSize,g.isMacintosh&&(t.className+=" mac"),t}(t),this._scrollable=i,this._register(this._scrollable.onScroll((e=>{this._onWillScroll.fire(e),this._onDidScroll(e),this._onScroll.fire(e)})));const s={onMouseWheel:e=>this._onMouseWheel(e),onDragStart:()=>this._onDragStart(),onDragEnd:()=>this._onDragEnd()};this._verticalScrollbar=this._register(new d.VerticalScrollbar(this._scrollable,this._options,s)),this._horizontalScrollbar=this._register(new c.HorizontalScrollbar(this._scrollable,this._options,s)),this._domNode=document.createElement("div"),this._domNode.className="xterm-scrollable-element "+this._options.className,this._domNode.setAttribute("role","presentation"),this._domNode.style.position="relative",this._domNode.appendChild(e),this._domNode.appendChild(this._horizontalScrollbar.domNode.domNode),this._domNode.appendChild(this._verticalScrollbar.domNode.domNode),this._options.useShadows?(this._leftShadowDomNode=(0,l.createFastDomNode)(document.createElement("div")),this._leftShadowDomNode.setClassName("shadow"),this._domNode.appendChild(this._leftShadowDomNode.domNode),this._topShadowDomNode=(0,l.createFastDomNode)(document.createElement("div")),this._topShadowDomNode.setClassName("shadow"),this._domNode.appendChild(this._topShadowDomNode.domNode),this._topLeftShadowDomNode=(0,l.createFastDomNode)(document.createElement("div")),this._topLeftShadowDomNode.setClassName("shadow"),this._domNode.appendChild(this._topLeftShadowDomNode.domNode)):(this._leftShadowDomNode=null,this._topShadowDomNode=null,this._topLeftShadowDomNode=null),this._listenOnDomNode=this._options.listenOnDomNode||this._domNode,this._mouseWheelToDispose=[],this._setListeningToMouseWheel(this._options.handleMouseWheel),this.onmouseover(this._listenOnDomNode,(e=>this._onMouseOver(e))),this.onmouseleave(this._listenOnDomNode,(e=>this._onMouseLeave(e))),this._hideTimeout=this._register(new _.TimeoutTimer),this._isDragging=!1,this._mouseIsOver=!1,this._shouldRender=!0,this._revealOnScroll=!0}dispose(){this._mouseWheelToDispose=(0,p.dispose)(this._mouseWheelToDispose),super.dispose()}getDomNode(){return this._domNode}getOverviewRulerLayoutInfo(){return{parent:this._domNode,insertBefore:this._verticalScrollbar.domNode.domNode}}delegateVerticalScrollbarPointerDown(e){this._verticalScrollbar.delegatePointerDown(e)}getScrollDimensions(){return this._scrollable.getScrollDimensions()}setScrollDimensions(e){this._scrollable.setScrollDimensions(e,!1)}updateClassName(e){this._options.className=e,g.isMacintosh&&(this._options.className+=" mac"),this._domNode.className="xterm-scrollable-element "+this._options.className}updateOptions(e){void 0!==e.handleMouseWheel&&(this._options.handleMouseWheel=e.handleMouseWheel,this._setListeningToMouseWheel(this._options.handleMouseWheel)),void 0!==e.mouseWheelScrollSensitivity&&(this._options.mouseWheelScrollSensitivity=e.mouseWheelScrollSensitivity),void 0!==e.fastScrollSensitivity&&(this._options.fastScrollSensitivity=e.fastScrollSensitivity),void 0!==e.scrollPredominantAxis&&(this._options.scrollPredominantAxis=e.scrollPredominantAxis),void 0!==e.horizontal&&(this._options.horizontal=e.horizontal),void 0!==e.vertical&&(this._options.vertical=e.vertical),void 0!==e.horizontalScrollbarSize&&(this._options.horizontalScrollbarSize=e.horizontalScrollbarSize),void 0!==e.verticalScrollbarSize&&(this._options.verticalScrollbarSize=e.verticalScrollbarSize),void 0!==e.scrollByPage&&(this._options.scrollByPage=e.scrollByPage),this._horizontalScrollbar.updateOptions(this._options),this._verticalScrollbar.updateOptions(this._options),this._options.lazyRender||this._render()}setRevealOnScroll(e){this._revealOnScroll=e}delegateScrollFromMouseWheelEvent(e){this._onMouseWheel(new h.StandardWheelEvent(e))}_setListeningToMouseWheel(e){if(this._mouseWheelToDispose.length>0!==e&&(this._mouseWheelToDispose=(0,p.dispose)(this._mouseWheelToDispose),e)){const e=e=>{this._onMouseWheel(new h.StandardWheelEvent(e))};this._mouseWheelToDispose.push(a.addDisposableListener(this._listenOnDomNode,a.EventType.MOUSE_WHEEL,e,{passive:!1}))}}_onMouseWheel(e){if(e.browserEvent?.defaultPrevented)return;const t=S.INSTANCE;t.acceptStandardWheelEvent(e);let i=!1;if(e.deltaY||e.deltaX){let s=e.deltaY*this._options.mouseWheelScrollSensitivity,r=e.deltaX*this._options.mouseWheelScrollSensitivity;this._options.scrollPredominantAxis&&(this._options.scrollYToX&&r+s===0?r=s=0:Math.abs(s)>=Math.abs(r)?r=0:s=0),this._options.flipAxes&&([s,r]=[r,s]);const n=!g.isMacintosh&&e.browserEvent&&e.browserEvent.shiftKey;!this._options.scrollYToX&&!n||r||(r=s,s=0),e.browserEvent&&e.browserEvent.altKey&&(r*=this._options.fastScrollSensitivity,s*=this._options.fastScrollSensitivity);const o=this._scrollable.getFutureScrollPosition();let a={};if(s){const e=50*s,t=o.scrollTop-(e<0?Math.floor(e):Math.ceil(e));this._verticalScrollbar.writeScrollPosition(a,t)}if(r){const e=50*r,t=o.scrollLeft-(e<0?Math.floor(e):Math.ceil(e));this._horizontalScrollbar.writeScrollPosition(a,t)}a=this._scrollable.validateScrollPosition(a),(o.scrollLeft!==a.scrollLeft||o.scrollTop!==a.scrollTop)&&(this._options.mouseWheelSmoothScroll&&t.isPhysicalMouseWheel()?this._scrollable.setScrollPositionSmooth(a):this._scrollable.setScrollPositionNow(a),i=!0)}let s=i;!s&&this._options.alwaysConsumeMouseWheel&&(s=!0),!s&&this._options.consumeMouseWheelIfScrollbarIsNeeded&&(this._verticalScrollbar.isNeeded()||this._horizontalScrollbar.isNeeded())&&(s=!0),s&&(e.preventDefault(),e.stopPropagation())}_onDidScroll(e){this._shouldRender=this._horizontalScrollbar.onDidScroll(e)||this._shouldRender,this._shouldRender=this._verticalScrollbar.onDidScroll(e)||this._shouldRender,this._options.useShadows&&(this._shouldRender=!0),this._revealOnScroll&&this._reveal(),this._options.lazyRender||this._render()}renderNow(){if(!this._options.lazyRender)throw new Error("Please use `lazyRender` together with `renderNow`!");this._render()}_render(){if(this._shouldRender&&(this._shouldRender=!1,this._horizontalScrollbar.render(),this._verticalScrollbar.render(),this._options.useShadows)){const e=this._scrollable.getCurrentScrollPosition(),t=e.scrollTop>0,i=e.scrollLeft>0,s=i?" left":"",r=t?" top":"",n=i||t?" top-left-corner":"";this._leftShadowDomNode.setClassName(`shadow${s}`),this._topShadowDomNode.setClassName(`shadow${r}`),this._topLeftShadowDomNode.setClassName(`shadow${n}${r}${s}`)}}_onDragStart(){this._isDragging=!0,this._reveal()}_onDragEnd(){this._isDragging=!1,this._hide()}_onMouseLeave(e){this._mouseIsOver=!1,this._hide()}_onMouseOver(e){this._mouseIsOver=!0,this._reveal()}_reveal(){this._verticalScrollbar.beginReveal(),this._horizontalScrollbar.beginReveal(),this._scheduleHide()}_hide(){this._mouseIsOver||this._isDragging||(this._verticalScrollbar.beginHide(),this._horizontalScrollbar.beginHide())}_scheduleHide(){this._mouseIsOver||this._isDragging||this._hideTimeout.cancelAndSet((()=>this._hide()),500)}}t.AbstractScrollableElement=b,t.ScrollableElement=class extends b{constructor(e,t){(t=t||{}).mouseWheelSmoothScroll=!1;const i=new m.Scrollable({forceIntegerValues:!0,smoothScrollDuration:0,scheduleAtNextAnimationFrame:t=>a.scheduleAtNextAnimationFrame(a.getWindow(e),t)});super(e,t,i),this._register(i)}setScrollPosition(e){this._scrollable.setScrollPositionNow(e)}getScrollPosition(){return this._scrollable.getCurrentScrollPosition()}},t.SmoothScrollableElement=class extends b{constructor(e,t,i){super(e,t,i)}setScrollPosition(e){e.reuseAnimation?this._scrollable.setScrollPositionSmooth(e,e.reuseAnimation):this._scrollable.setScrollPositionNow(e)}getScrollPosition(){return this._scrollable.getCurrentScrollPosition()}},t.DomScrollableElement=class extends b{constructor(e,t){(t=t||{}).mouseWheelSmoothScroll=!1;const i=new m.Scrollable({forceIntegerValues:!1,smoothScrollDuration:0,scheduleAtNextAnimationFrame:t=>a.scheduleAtNextAnimationFrame(a.getWindow(e),t)});super(e,t,i),this._register(i),this._element=e,this._register(this.onScroll((e=>{e.scrollTopChanged&&(this._element.scrollTop=e.scrollTop),e.scrollLeftChanged&&(this._element.scrollLeft=e.scrollLeft)}))),this.scanDomNode()}setScrollPosition(e){this._scrollable.setScrollPositionNow(e)}getScrollPosition(){return this._scrollable.getCurrentScrollPosition()}scanDomNode(){this.setScrollDimensions({width:this._element.clientWidth,scrollWidth:this._element.scrollWidth,height:this._element.clientHeight,scrollHeight:this._element.scrollHeight}),this.setScrollPosition({scrollLeft:this._element.scrollLeft,scrollTop:this._element.scrollTop})}}},8974:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.ScrollbarArrow=t.ARROW_IMG_SIZE=void 0;const o=i(8328),a=i(8286),l=i(1758),h=n(i(7093));t.ARROW_IMG_SIZE=11;class c extends a.Widget{constructor(e){super(),this._onActivate=e.onActivate,this.bgDomNode=document.createElement("div"),this.bgDomNode.className="arrow-background",this.bgDomNode.style.position="absolute",this.bgDomNode.style.width=e.bgWidth+"px",this.bgDomNode.style.height=e.bgHeight+"px",void 0!==e.top&&(this.bgDomNode.style.top="0px"),void 0!==e.left&&(this.bgDomNode.style.left="0px"),void 0!==e.bottom&&(this.bgDomNode.style.bottom="0px"),void 0!==e.right&&(this.bgDomNode.style.right="0px"),this.domNode=document.createElement("div"),this.domNode.className=e.className,this.domNode.style.position="absolute",this.domNode.style.width=t.ARROW_IMG_SIZE+"px",this.domNode.style.height=t.ARROW_IMG_SIZE+"px",void 0!==e.top&&(this.domNode.style.top=e.top+"px"),void 0!==e.left&&(this.domNode.style.left=e.left+"px"),void 0!==e.bottom&&(this.domNode.style.bottom=e.bottom+"px"),void 0!==e.right&&(this.domNode.style.right=e.right+"px"),this._pointerMoveMonitor=this._register(new o.GlobalPointerMoveMonitor),this._register(h.addStandardDisposableListener(this.bgDomNode,h.EventType.POINTER_DOWN,(e=>this._arrowPointerDown(e)))),this._register(h.addStandardDisposableListener(this.domNode,h.EventType.POINTER_DOWN,(e=>this._arrowPointerDown(e)))),this._pointerdownRepeatTimer=this._register(new h.WindowIntervalTimer),this._pointerdownScheduleRepeatTimer=this._register(new l.TimeoutTimer)}_arrowPointerDown(e){e.target&&e.target instanceof Element&&(this._onActivate(),this._pointerdownRepeatTimer.cancel(),this._pointerdownScheduleRepeatTimer.cancelAndSet((()=>{this._pointerdownRepeatTimer.cancelAndSet((()=>this._onActivate()),1e3/24,h.getWindow(e))}),200),this._pointerMoveMonitor.startMonitoring(e.target,e.pointerId,e.buttons,(e=>{}),(()=>{this._pointerdownRepeatTimer.cancel(),this._pointerdownScheduleRepeatTimer.cancel()})),e.preventDefault())}}t.ScrollbarArrow=c},8245:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ScrollbarState=void 0;class i{constructor(e,t,i,s,r,n){this._scrollbarSize=Math.round(t),this._oppositeScrollbarSize=Math.round(i),this._arrowSize=Math.round(e),this._visibleSize=s,this._scrollSize=r,this._scrollPosition=n,this._computedAvailableSize=0,this._computedIsNeeded=!1,this._computedSliderSize=0,this._computedSliderRatio=0,this._computedSliderPosition=0,this._refreshComputedValues()}clone(){return new i(this._arrowSize,this._scrollbarSize,this._oppositeScrollbarSize,this._visibleSize,this._scrollSize,this._scrollPosition)}setVisibleSize(e){const t=Math.round(e);return this._visibleSize!==t&&(this._visibleSize=t,this._refreshComputedValues(),!0)}setScrollSize(e){const t=Math.round(e);return this._scrollSize!==t&&(this._scrollSize=t,this._refreshComputedValues(),!0)}setScrollPosition(e){const t=Math.round(e);return this._scrollPosition!==t&&(this._scrollPosition=t,this._refreshComputedValues(),!0)}setScrollbarSize(e){this._scrollbarSize=Math.round(e)}setOppositeScrollbarSize(e){this._oppositeScrollbarSize=Math.round(e)}static _computeValues(e,t,i,s,r){const n=Math.max(0,i-e),o=Math.max(0,n-2*t),a=s>0&&s>i;if(!a)return{computedAvailableSize:Math.round(n),computedIsNeeded:a,computedSliderSize:Math.round(o),computedSliderRatio:0,computedSliderPosition:0};const l=Math.round(Math.max(20,Math.floor(i*o/s))),h=(o-l)/(s-i),c=r*h;return{computedAvailableSize:Math.round(n),computedIsNeeded:a,computedSliderSize:Math.round(l),computedSliderRatio:h,computedSliderPosition:Math.round(c)}}_refreshComputedValues(){const e=i._computeValues(this._oppositeScrollbarSize,this._arrowSize,this._visibleSize,this._scrollSize,this._scrollPosition);this._computedAvailableSize=e.computedAvailableSize,this._computedIsNeeded=e.computedIsNeeded,this._computedSliderSize=e.computedSliderSize,this._computedSliderRatio=e.computedSliderRatio,this._computedSliderPosition=e.computedSliderPosition}getArrowSize(){return this._arrowSize}getScrollPosition(){return this._scrollPosition}getRectangleLargeSize(){return this._computedAvailableSize}getRectangleSmallSize(){return this._scrollbarSize}isNeeded(){return this._computedIsNeeded}getSliderSize(){return this._computedSliderSize}getSliderPosition(){return this._computedSliderPosition}getDesiredScrollPositionFromOffset(e){if(!this._computedIsNeeded)return 0;const t=e-this._arrowSize-this._computedSliderSize/2;return Math.round(t/this._computedSliderRatio)}getDesiredScrollPositionFromOffsetPaged(e){if(!this._computedIsNeeded)return 0;const t=e-this._arrowSize;let i=this._scrollPosition;return t{Object.defineProperty(t,"__esModule",{value:!0}),t.ScrollbarVisibilityController=void 0;const s=i(1758),r=i(7150),n=i(9881);class o extends r.Disposable{constructor(e,t,i){super(),this._visibility=e,this._visibleClassName=t,this._invisibleClassName=i,this._domNode=null,this._isVisible=!1,this._isNeeded=!1,this._rawShouldBeVisible=!1,this._shouldBeVisible=!1,this._revealTimer=this._register(new s.TimeoutTimer)}setVisibility(e){this._visibility!==e&&(this._visibility=e,this._updateShouldBeVisible())}setShouldBeVisible(e){this._rawShouldBeVisible=e,this._updateShouldBeVisible()}_applyVisibilitySetting(){return this._visibility!==n.ScrollbarVisibility.Hidden&&(this._visibility===n.ScrollbarVisibility.Visible||this._rawShouldBeVisible)}_updateShouldBeVisible(){const e=this._applyVisibilitySetting();this._shouldBeVisible!==e&&(this._shouldBeVisible=e,this.ensureVisibility())}setIsNeeded(e){this._isNeeded!==e&&(this._isNeeded=e,this.ensureVisibility())}setDomNode(e){this._domNode=e,this._domNode.setClassName(this._invisibleClassName),this.setShouldBeVisible(!1)}ensureVisibility(){this._isNeeded?this._shouldBeVisible?this._reveal():this._hide(!0):this._hide(!1)}_reveal(){this._isVisible||(this._isVisible=!0,this._revealTimer.setIfNotSet((()=>{this._domNode?.setClassName(this._visibleClassName)}),0))}_hide(e){this._revealTimer.cancel(),this._isVisible&&(this._isVisible=!1,this._domNode?.setClassName(this._invisibleClassName+(e?" fade":"")))}}t.ScrollbarVisibilityController=o},5473:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.VerticalScrollbar=void 0;const s=i(8801),r=i(8245),n=i(9881);class o extends s.AbstractScrollbar{constructor(e,t,i){const s=e.getScrollDimensions(),o=e.getCurrentScrollPosition();if(super({lazyRender:t.lazyRender,host:i,scrollbarState:new r.ScrollbarState(t.verticalHasArrows?t.arrowSize:0,t.vertical===n.ScrollbarVisibility.Hidden?0:t.verticalScrollbarSize,0,s.height,s.scrollHeight,o.scrollTop),visibility:t.vertical,extraScrollbarClassName:"vertical",scrollable:e,scrollByPage:t.scrollByPage}),t.verticalHasArrows)throw new Error("horizontalHasArrows is not supported in xterm.js");this._createSlider(0,Math.floor((t.verticalScrollbarSize-t.verticalSliderSize)/2),t.verticalSliderSize,void 0)}_updateSlider(e,t){this.slider.setHeight(e),this.slider.setTop(t)}_renderDomNode(e,t){this.domNode.setWidth(t),this.domNode.setHeight(e),this.domNode.setRight(0),this.domNode.setTop(0)}onDidScroll(e){return this._shouldRender=this._onElementScrollSize(e.scrollHeight)||this._shouldRender,this._shouldRender=this._onElementScrollPosition(e.scrollTop)||this._shouldRender,this._shouldRender=this._onElementSize(e.height)||this._shouldRender,this._shouldRender}_pointerDownRelativePosition(e,t){return t}_sliderPointerPosition(e){return e.pageY}_sliderOrthogonalPointerPosition(e){return e.pageX}_updateScrollbarSize(e){this.slider.setWidth(e)}writeScrollPosition(e,t){e.scrollTop=t}updateOptions(e){this.updateScrollbarSize(e.vertical===n.ScrollbarVisibility.Hidden?0:e.verticalScrollbarSize),this._scrollbarState.setOppositeScrollbarSize(0),this._visibilityController.setVisibility(e.vertical),this._scrollByPage=e.scrollByPage}}t.VerticalScrollbar=o},8286:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.Widget=void 0;const o=n(i(7093)),a=i(5394),l=i(5964),h=i(8594),c=i(7150);class d extends c.Disposable{onclick(e,t){this._register(o.addDisposableListener(e,o.EventType.CLICK,(i=>t(new l.StandardMouseEvent(o.getWindow(e),i)))))}onmousedown(e,t){this._register(o.addDisposableListener(e,o.EventType.MOUSE_DOWN,(i=>t(new l.StandardMouseEvent(o.getWindow(e),i)))))}onmouseover(e,t){this._register(o.addDisposableListener(e,o.EventType.MOUSE_OVER,(i=>t(new l.StandardMouseEvent(o.getWindow(e),i)))))}onmouseleave(e,t){this._register(o.addDisposableListener(e,o.EventType.MOUSE_LEAVE,(i=>t(new l.StandardMouseEvent(o.getWindow(e),i)))))}onkeydown(e,t){this._register(o.addDisposableListener(e,o.EventType.KEY_DOWN,(e=>t(new a.StandardKeyboardEvent(e)))))}onkeyup(e,t){this._register(o.addDisposableListener(e,o.EventType.KEY_UP,(e=>t(new a.StandardKeyboardEvent(e)))))}oninput(e,t){this._register(o.addDisposableListener(e,o.EventType.INPUT,t))}onblur(e,t){this._register(o.addDisposableListener(e,o.EventType.BLUR,t))}onfocus(e,t){this._register(o.addDisposableListener(e,o.EventType.FOCUS,t))}onchange(e,t){this._register(o.addDisposableListener(e,o.EventType.CHANGE,t))}ignoreGesture(e){return h.Gesture.ignoreTarget(e)}}t.Widget=d},4693:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.mainWindow=void 0,t.ensureCodeWindow=function(e,t){},t.mainWindow="object"==typeof window?window:globalThis},3058:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Permutation=t.CallbackIterable=t.ArrayQueue=t.booleanComparator=t.numberComparator=t.CompareResult=void 0,t.tail=function(e,t=0){return e[e.length-(1+t)]},t.tail2=function(e){if(0===e.length)throw new Error("Invalid tail call");return[e.slice(0,e.length-1),e[e.length-1]]},t.equals=function(e,t,i=(e,t)=>e===t){if(e===t)return!0;if(!e||!t)return!1;if(e.length!==t.length)return!1;for(let s=0,r=e.length;si(e[s],t)))},t.binarySearch2=n,t.quickSelect=function e(t,i,s){if((t|=0)>=i.length)throw new TypeError("invalid index");const r=i[Math.floor(i.length*Math.random())],n=[],o=[],a=[];for(const e of i){const t=s(e,r);t<0?n.push(e):t>0?o.push(e):a.push(e)}return t{(async()=>{const o=e.length,l=e.slice(0,i).sort(t);for(let h=i,c=Math.min(i+r,o);hi&&await new Promise((e=>setTimeout(e))),n&&n.isCancellationRequested)throw new s.CancellationError;a(e,t,l,h,c)}return l})().then(o,l)}))},t.coalesce=function(e){return e.filter((e=>!!e))},t.coalesceInPlace=function(e){let t=0;for(let i=0;i0},t.distinct=function(e,t=e=>e){const i=new Set;return e.filter((e=>{const s=t(e);return!i.has(s)&&(i.add(s),!0)}))},t.uniqueFilter=function(e){const t=new Set;return i=>{const s=e(i);return!t.has(s)&&(t.add(s),!0)}},t.firstOrDefault=function(e,t){return e.length>0?e[0]:t},t.lastOrDefault=function(e,t){return e.length>0?e[e.length-1]:t},t.commonPrefixLength=function(e,t,i=(e,t)=>e===t){let s=0;for(let r=0,n=Math.min(e.length,t.length);rt;e--)s.push(e);return s},t.index=function(e,t,i){return e.reduce(((e,s)=>(e[t(s)]=i?i(s):s,e)),Object.create(null))},t.insert=function(e,t){return e.push(t),()=>l(e,t)},t.remove=l,t.arrayInsert=function(e,t,i){const s=e.slice(0,t),r=e.slice(t);return s.concat(i,r)},t.shuffle=function(e,t){let i;if("number"==typeof t){let e=t;i=()=>{const t=179426549*Math.sin(e++);return t-Math.floor(t)}}else i=Math.random;for(let t=e.length-1;t>0;t-=1){const s=Math.floor(i()*(t+1)),r=e[t];e[t]=e[s],e[s]=r}},t.pushToStart=function(e,t){const i=e.indexOf(t);i>-1&&(e.splice(i,1),e.unshift(t))},t.pushToEnd=function(e,t){const i=e.indexOf(t);i>-1&&(e.splice(i,1),e.push(t))},t.pushMany=function(e,t){for(const i of t)e.push(i)},t.mapArrayOrNot=function(e,t){return Array.isArray(e)?e.map(t):t(e)},t.asArray=function(e){return Array.isArray(e)?e:[e]},t.getRandomElement=function(e){return e[Math.floor(Math.random()*e.length)]},t.insertInto=h,t.splice=function(e,t,i,s){const r=c(e,t);let n=e.splice(r,i);return void 0===n&&(n=[]),h(e,r,s),n},t.compareBy=function(e,t){return(i,s)=>t(e(i),e(s))},t.tieBreakComparators=function(...e){return(t,i)=>{for(const s of e){const e=s(t,i);if(!d.isNeitherLessOrGreaterThan(e))return e}return d.neitherLessOrGreaterThan}},t.reverseOrder=function(e){return(t,i)=>-e(t,i)};const s=i(9807),r=i(8297);function n(e,t){let i=0,s=e-1;for(;i<=s;){const e=(i+s)/2|0,r=t(e);if(r<0)i=e+1;else{if(!(r>0))return e;s=e-1}}return-(i+1)}function o(e,t,i){const s=[];function r(e,t,i){if(0===t&&0===i.length)return;const r=s[s.length-1];r&&r.start+r.deleteCount===e?(r.deleteCount+=t,r.toInsert.push(...i)):s.push({start:e,deleteCount:t,toInsert:i})}let n=0,o=0;for(;;){if(n===e.length){r(n,0,t.slice(o));break}if(o===t.length){r(n,e.length-n,[]);break}const s=e[n],a=t[o],l=i(s,a);0===l?(n+=1,o+=1):l<0?(r(n,1,[]),n+=1):l>0&&(r(n,0,[a]),o+=1)}return s}function a(e,t,i,s,n){for(const o=i.length;st(n,e)<0));i.splice(e,0,n)}}}function l(e,t){const i=e.indexOf(t);if(i>-1)return e.splice(i,1),t}function h(e,t,i){const s=c(e,t),r=e.length,n=i.length;e.length=r+n;for(let t=r-1;t>=s;t--)e[t+n]=e[t];for(let t=0;t0},e.isNeitherLessOrGreaterThan=function(e){return 0===e},e.greaterThan=1,e.lessThan=-1,e.neitherLessOrGreaterThan=0}(d||(t.CompareResult=d={})),t.numberComparator=(e,t)=>e-t,t.booleanComparator=(e,i)=>(0,t.numberComparator)(e?1:0,i?1:0),t.ArrayQueue=class{constructor(e){this.items=e,this.firstIdx=0,this.lastIdx=this.items.length-1}get length(){return this.lastIdx-this.firstIdx+1}takeWhile(e){let t=this.firstIdx;for(;t=0&&e(this.items[t]);)t--;const i=t===this.lastIdx?null:this.items.slice(t+1,this.lastIdx+1);return this.lastIdx=t,i}peek(){if(0!==this.length)return this.items[this.firstIdx]}peekLast(){if(0!==this.length)return this.items[this.lastIdx]}dequeue(){const e=this.items[this.firstIdx];return this.firstIdx++,e}removeLast(){const e=this.items[this.lastIdx];return this.lastIdx--,e}takeCount(e){const t=this.items.slice(this.firstIdx,this.firstIdx+e);return this.firstIdx+=e,t}};class u{static{this.empty=new u((e=>{}))}constructor(e){this.iterate=e}forEach(e){this.iterate((t=>(e(t),!0)))}toArray(){const e=[];return this.iterate((t=>(e.push(t),!0))),e}filter(e){return new u((t=>this.iterate((i=>!e(i)||t(i)))))}map(e){return new u((t=>this.iterate((i=>t(e(i))))))}some(e){let t=!1;return this.iterate((i=>(t=e(i),!t))),t}findFirst(e){let t;return this.iterate((i=>!e(i)||(t=i,!1))),t}findLast(e){let t;return this.iterate((i=>(e(i)&&(t=i),!0))),t}findLastMaxBy(e){let t,i=!0;return this.iterate((s=>((i||d.isGreaterThan(e(s,t)))&&(i=!1,t=s),!0))),t}}t.CallbackIterable=u;class _{constructor(e){this._indexMap=e}static createSortPermutation(e,t){const i=Array.from(e.keys()).sort(((i,s)=>t(e[i],e[s])));return new _(i)}apply(e){return e.map(((t,i)=>e[this._indexMap[i]]))}inverse(){const e=this._indexMap.slice();for(let t=0;t{function i(e,t,i=e.length-1){for(let s=i;s>=0;s--)if(t(e[s]))return s;return-1}function s(e,t,i=0,s=e.length){let r=i,n=s;for(;r=0&&(i=r)}return i},t.findFirstMin=function(e,t){return o(e,((e,i)=>-t(e,i)))},t.findMaxIdx=function(e,t){if(0===e.length)return-1;let i=0;for(let s=1;s0&&(i=s);return i},t.mapFindFirst=function(e,t){for(const i of e){const e=t(i);if(void 0!==e)return e}};class n{static{this.assertInvariants=!1}constructor(e){this._array=e,this._findLastMonotonousLastIdx=0}findLastMonotonous(e){if(n.assertInvariants){if(this._prevFindLastPredicate)for(const t of this._array)if(this._prevFindLastPredicate(t)&&!e(t))throw new Error("MonotonousArray: current predicate must be weaker than (or equal to) the previous predicate.");this._prevFindLastPredicate=e}const t=s(this._array,e,this._findLastMonotonousLastIdx);return this._findLastMonotonousLastIdx=t+1,-1===t?void 0:this._array[t]}}function o(e,t){if(0===e.length)return;let i=e[0];for(let s=1;s0&&(i=r)}return i}t.MonotonousArray=n},1758:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.AsyncIterableSource=t.CancelableAsyncIterableObject=t.AsyncIterableObject=t.LazyStatefulPromise=t.StatefulPromise=t.Promises=t.DeferredPromise=t.IntervalCounter=t.TaskSequentializer=t.GlobalIdleValue=t.AbstractIdleValue=t._runWhenIdle=t.runWhenGlobalIdle=t.ThrottledWorker=t.RunOnceWorker=t.ProcessTimeRunOnceScheduler=t.RunOnceScheduler=t.IntervalTimer=t.TimeoutTimer=t.LimitedQueue=t.Queue=t.Limiter=t.AutoOpenBarrier=t.Barrier=t.ThrottledDelayer=t.Delayer=t.SequencerByKey=t.Sequencer=t.Throttler=void 0,t.isThenable=c,t.createCancelablePromise=d,t.raceCancellation=function(e,t,i){return new Promise(((s,r)=>{const n=t.onCancellationRequested((()=>{n.dispose(),s(i)}));e.then(s,r).finally((()=>n.dispose()))}))},t.raceCancellationError=function(e,t){return new Promise(((i,s)=>{const n=t.onCancellationRequested((()=>{n.dispose(),s(new r.CancellationError)}));e.then(i,s).finally((()=>n.dispose()))}))},t.raceCancellablePromises=async function(e){let t=-1;const i=e.map(((e,i)=>e.then((e=>(t=i,e)))));try{return await Promise.race(i)}finally{e.forEach(((e,i)=>{i!==t&&e.cancel()}))}},t.raceTimeout=function(e,t,i){let s;const r=setTimeout((()=>{s?.(void 0),i?.()}),t);return Promise.race([e.finally((()=>clearTimeout(r))),new Promise((e=>s=e))])},t.asPromise=function(e){return new Promise(((t,i)=>{const s=e();c(s)?s.then(t,i):t(s)}))},t.promiseWithResolvers=u,t.timeout=g,t.disposableTimeout=function(e,t=0,i){const s=setTimeout((()=>{e(),i&&r.dispose()}),t),r=(0,o.toDisposable)((()=>{clearTimeout(s),i?.deleteAndLeak(r)}));return i?.add(r),r},t.sequence=function(e){const t=[];let i=0;const s=e.length;return Promise.resolve(null).then((function r(n){null!=n&&t.push(n);const o=i!!e,i=null){let s=0;const r=e.length,n=()=>{if(s>=r)return Promise.resolve(i);const o=e[s++];return Promise.resolve(o()).then((e=>t(e)?Promise.resolve(e):n()))};return n()},t.firstParallel=function(e,t=e=>!!e,i=null){if(0===e.length)return Promise.resolve(i);let s=e.length;const r=()=>{s=-1;for(const t of e)t.cancel?.()};return new Promise(((n,o)=>{for(const a of e)a.then((e=>{--s>=0&&t(e)?(r(),n(e)):0===s&&n(i)})).catch((e=>{--s>=0&&(r(),o(e))}))}))},t.retry=async function(e,t,i){let s;for(let r=0;r{const s=t.token.onCancellationRequested((()=>{s.dispose(),t.dispose(),e.reject(new r.CancellationError)}));try{for await(const s of i){if(t.token.isCancellationRequested)return;e.emitOne(s)}s.dispose(),t.dispose()}catch(i){s.dispose(),t.dispose(),e.reject(i)}}))};const s=i(8447),r=i(9807),n=i(802),o=i(7150),a=i(8163),l=i(5015),h=i(626);function c(e){return!!e&&"function"==typeof e.then}function d(e){const t=new s.CancellationTokenSource,i=e(t.token),n=new Promise(((e,s)=>{const n=t.token.onCancellationRequested((()=>{n.dispose(),s(new r.CancellationError)}));Promise.resolve(i).then((i=>{n.dispose(),t.dispose(),e(i)}),(e=>{n.dispose(),t.dispose(),s(e)}))}));return new class{cancel(){t.cancel(),t.dispose()}then(e,t){return n.then(e,t)}catch(e){return this.then(void 0,e)}finally(e){return n.finally(e)}}}function u(){let e,t;return{promise:new Promise(((i,s)=>{e=i,t=s})),resolve:e,reject:t}}class _{constructor(){this.isDisposed=!1,this.activePromise=null,this.queuedPromise=null,this.queuedPromiseFactory=null}queue(e){if(this.isDisposed)return Promise.reject(new Error("Throttler is disposed"));if(this.activePromise){if(this.queuedPromiseFactory=e,!this.queuedPromise){const e=()=>{if(this.queuedPromise=null,this.isDisposed)return;const e=this.queue(this.queuedPromiseFactory);return this.queuedPromiseFactory=null,e};this.queuedPromise=new Promise((t=>{this.activePromise.then(e,e).then(t)}))}return new Promise(((e,t)=>{this.queuedPromise.then(e,t)}))}return this.activePromise=e(),new Promise(((e,t)=>{this.activePromise.then((t=>{this.activePromise=null,e(t)}),(e=>{this.activePromise=null,t(e)}))}))}dispose(){this.isDisposed=!0}}t.Throttler=_,t.Sequencer=class{constructor(){this.current=Promise.resolve(null)}queue(e){return this.current=this.current.then((()=>e()),(()=>e()))}},t.SequencerByKey=class{constructor(){this.promiseMap=new Map}queue(e,t){const i=(this.promiseMap.get(e)??Promise.resolve()).catch((()=>{})).then(t).finally((()=>{this.promiseMap.get(e)===i&&this.promiseMap.delete(e)}));return this.promiseMap.set(e,i),i}};class f{constructor(e){this.defaultDelay=e,this.deferred=null,this.completionPromise=null,this.doResolve=null,this.doReject=null,this.task=null}trigger(e,t=this.defaultDelay){this.task=e,this.cancelTimeout(),this.completionPromise||(this.completionPromise=new Promise(((e,t)=>{this.doResolve=e,this.doReject=t})).then((()=>{if(this.completionPromise=null,this.doResolve=null,this.task){const e=this.task;return this.task=null,e()}})));const i=()=>{this.deferred=null,this.doResolve?.(null)};return this.deferred=t===l.MicrotaskDelay?(e=>{let t=!0;return queueMicrotask((()=>{t&&(t=!1,e())})),{isTriggered:()=>t,dispose:()=>{t=!1}}})(i):((e,t)=>{let i=!0;const s=setTimeout((()=>{i=!1,t()}),e);return{isTriggered:()=>i,dispose:()=>{clearTimeout(s),i=!1}}})(t,i),this.completionPromise}isTriggered(){return!!this.deferred?.isTriggered()}cancel(){this.cancelTimeout(),this.completionPromise&&(this.doReject?.(new r.CancellationError),this.completionPromise=null)}cancelTimeout(){this.deferred?.dispose(),this.deferred=null}dispose(){this.cancel()}}t.Delayer=f,t.ThrottledDelayer=class{constructor(e){this.delayer=new f(e),this.throttler=new _}trigger(e,t){return this.delayer.trigger((()=>this.throttler.queue(e)),t)}isTriggered(){return this.delayer.isTriggered()}cancel(){this.delayer.cancel()}dispose(){this.delayer.dispose(),this.throttler.dispose()}};class p{constructor(){this._isOpen=!1,this._promise=new Promise(((e,t)=>{this._completePromise=e}))}isOpen(){return this._isOpen}open(){this._isOpen=!0,this._completePromise(!0)}wait(){return this._promise}}function g(e,t){return t?new Promise(((i,s)=>{const n=setTimeout((()=>{o.dispose(),i()}),e),o=t.onCancellationRequested((()=>{clearTimeout(n),o.dispose(),s(new r.CancellationError)}))})):d((t=>g(e,t)))}t.Barrier=p,t.AutoOpenBarrier=class extends p{constructor(e){super(),this._timeout=setTimeout((()=>this.open()),e)}open(){clearTimeout(this._timeout),super.open()}};class m{constructor(e){this._size=0,this._isDisposed=!1,this.maxDegreeOfParalellism=e,this.outstandingPromises=[],this.runningPromises=0,this._onDrained=new n.Emitter}whenIdle(){return this.size>0?n.Event.toPromise(this.onDrained):Promise.resolve()}get onDrained(){return this._onDrained.event}get size(){return this._size}queue(e){if(this._isDisposed)throw new Error("Object has been disposed");return this._size++,new Promise(((t,i)=>{this.outstandingPromises.push({factory:e,c:t,e:i}),this.consume()}))}consume(){for(;this.outstandingPromises.length&&this.runningPromisesthis.consumed()),(()=>this.consumed()))}}consumed(){this._isDisposed||(this.runningPromises--,0==--this._size&&this._onDrained.fire(),this.outstandingPromises.length>0&&this.consume())}clear(){if(this._isDisposed)throw new Error("Object has been disposed");this.outstandingPromises.length=0,this._size=this.runningPromises}dispose(){this._isDisposed=!0,this.outstandingPromises.length=0,this._size=0,this._onDrained.dispose()}}t.Limiter=m,t.Queue=class extends m{constructor(){super(1)}},t.LimitedQueue=class{constructor(){this.sequentializer=new C,this.tasks=0}queue(e){return this.sequentializer.isRunning()?this.sequentializer.queue((()=>this.sequentializer.run(this.tasks++,e()))):this.sequentializer.run(this.tasks++,e())}},t.TimeoutTimer=class{constructor(e,t){this._isDisposed=!1,this._token=-1,"function"==typeof e&&"number"==typeof t&&this.setIfNotSet(e,t)}dispose(){this.cancel(),this._isDisposed=!0}cancel(){-1!==this._token&&(clearTimeout(this._token),this._token=-1)}cancelAndSet(e,t){if(this._isDisposed)throw new r.BugIndicatingError("Calling 'cancelAndSet' on a disposed TimeoutTimer");this.cancel(),this._token=setTimeout((()=>{this._token=-1,e()}),t)}setIfNotSet(e,t){if(this._isDisposed)throw new r.BugIndicatingError("Calling 'setIfNotSet' on a disposed TimeoutTimer");-1===this._token&&(this._token=setTimeout((()=>{this._token=-1,e()}),t))}},t.IntervalTimer=class{constructor(){this.disposable=void 0,this.isDisposed=!1}cancel(){this.disposable?.dispose(),this.disposable=void 0}cancelAndSet(e,t,i=globalThis){if(this.isDisposed)throw new r.BugIndicatingError("Calling 'cancelAndSet' on a disposed IntervalTimer");this.cancel();const s=i.setInterval((()=>{e()}),t);this.disposable=(0,o.toDisposable)((()=>{i.clearInterval(s),this.disposable=void 0}))}dispose(){this.cancel(),this.isDisposed=!0}};class v{constructor(e,t){this.timeoutToken=-1,this.runner=e,this.timeout=t,this.timeoutHandler=this.onTimeout.bind(this)}dispose(){this.cancel(),this.runner=null}cancel(){this.isScheduled()&&(clearTimeout(this.timeoutToken),this.timeoutToken=-1)}schedule(e=this.timeout){this.cancel(),this.timeoutToken=setTimeout(this.timeoutHandler,e)}get delay(){return this.timeout}set delay(e){this.timeout=e}isScheduled(){return-1!==this.timeoutToken}flush(){this.isScheduled()&&(this.cancel(),this.doRun())}onTimeout(){this.timeoutToken=-1,this.runner&&this.doRun()}doRun(){this.runner?.()}}t.RunOnceScheduler=v,t.ProcessTimeRunOnceScheduler=class{constructor(e,t){t%1e3!=0&&console.warn(`ProcessTimeRunOnceScheduler resolution is 1s, ${t}ms is not a multiple of 1000ms.`),this.runner=e,this.timeout=t,this.counter=0,this.intervalToken=-1,this.intervalHandler=this.onInterval.bind(this)}dispose(){this.cancel(),this.runner=null}cancel(){this.isScheduled()&&(clearInterval(this.intervalToken),this.intervalToken=-1)}schedule(e=this.timeout){e%1e3!=0&&console.warn(`ProcessTimeRunOnceScheduler resolution is 1s, ${e}ms is not a multiple of 1000ms.`),this.cancel(),this.counter=Math.ceil(e/1e3),this.intervalToken=setInterval(this.intervalHandler,1e3)}isScheduled(){return-1!==this.intervalToken}onInterval(){this.counter--,this.counter>0||(clearInterval(this.intervalToken),this.intervalToken=-1,this.runner?.())}},t.RunOnceWorker=class extends v{constructor(e,t){super(e,t),this.units=[]}work(e){this.units.push(e),this.isScheduled()||this.schedule()}doRun(){const e=this.units;this.units=[],this.runner?.(e)}dispose(){this.units=[],super.dispose()}};class S extends o.Disposable{constructor(e,t){super(),this.options=e,this.handler=t,this.pendingWork=[],this.throttler=this._register(new o.MutableDisposable),this.disposed=!1}get pending(){return this.pendingWork.length}work(e){if(this.disposed)return!1;if("number"==typeof this.options.maxBufferedWork)if(this.throttler.value){if(this.pending+e.length>this.options.maxBufferedWork)return!1}else if(this.pending+e.length-this.options.maxWorkChunkSize>this.options.maxBufferedWork)return!1;for(const t of e)this.pendingWork.push(t);return this.throttler.value||this.doWork(),!0}doWork(){this.handler(this.pendingWork.splice(0,this.options.maxWorkChunkSize)),this.pendingWork.length>0&&(this.throttler.value=new v((()=>{this.throttler.clear(),this.doWork()}),this.options.throttleDelay),this.throttler.value.schedule())}dispose(){super.dispose(),this.disposed=!0}}t.ThrottledWorker=S,"function"!=typeof globalThis.requestIdleCallback||"function"!=typeof globalThis.cancelIdleCallback?t._runWhenIdle=(e,t)=>{(0,a.setTimeout0)((()=>{if(i)return;const e=Date.now()+15,s={didTimeout:!0,timeRemaining:()=>Math.max(0,e-Date.now())};t(Object.freeze(s))}));let i=!1;return{dispose(){i||(i=!0)}}}:t._runWhenIdle=(e,t,i)=>{const s=e.requestIdleCallback(t,"number"==typeof i?{timeout:i}:void 0);let r=!1;return{dispose(){r||(r=!0,e.cancelIdleCallback(s))}}},t.runWhenGlobalIdle=e=>(0,t._runWhenIdle)(globalThis,e);class b{constructor(e,i){this._didRun=!1,this._executor=()=>{try{this._value=i()}catch(e){this._error=e}finally{this._didRun=!0}},this._handle=(0,t._runWhenIdle)(e,(()=>this._executor()))}dispose(){this._handle.dispose()}get value(){if(this._didRun||(this._handle.dispose(),this._executor()),this._error)throw this._error;return this._value}get isInitialized(){return this._didRun}}t.AbstractIdleValue=b,t.GlobalIdleValue=class extends b{constructor(e){super(globalThis,e)}};class C{isRunning(e){return"number"==typeof e?this._running?.taskId===e:!!this._running}get running(){return this._running?.promise}cancelRunning(){this._running?.cancel()}run(e,t,i){return this._running={taskId:e,cancel:()=>i?.(),promise:t},t.then((()=>this.doneRunning(e)),(()=>this.doneRunning(e))),t}doneRunning(e){this._running&&e===this._running.taskId&&(this._running=void 0,this.runQueued())}runQueued(){if(this._queued){const e=this._queued;this._queued=void 0,e.run().then(e.promiseResolve,e.promiseReject)}}queue(e){if(this._queued)this._queued.run=e;else{const{promise:t,resolve:i,reject:s}=u();this._queued={run:e,promise:t,promiseResolve:i,promiseReject:s}}return this._queued.promise}hasQueued(){return!!this._queued}async join(){return this._queued?.promise??this._running?.promise}}var y,w,E;t.TaskSequentializer=C,t.IntervalCounter=class{constructor(e,t=()=>Date.now()){this.interval=e,this.nowFn=t,this.lastIncrementTime=0,this.value=0}increment(){const e=this.nowFn();return e-this.lastIncrementTime>this.interval&&(this.lastIncrementTime=e,this.value=0),this.value++,this.value}},function(e){e[e.Resolved=0]="Resolved",e[e.Rejected=1]="Rejected"}(y||(y={}));class D{get isRejected(){return this.outcome?.outcome===y.Rejected}get isResolved(){return this.outcome?.outcome===y.Resolved}get isSettled(){return!!this.outcome}get value(){return this.outcome?.outcome===y.Resolved?this.outcome?.value:void 0}constructor(){this.p=new Promise(((e,t)=>{this.completeCallback=e,this.errorCallback=t}))}complete(e){return new Promise((t=>{this.completeCallback(e),this.outcome={outcome:y.Resolved,value:e},t()}))}error(e){return new Promise((t=>{this.errorCallback(e),this.outcome={outcome:y.Rejected,value:e},t()}))}cancel(){return this.error(new r.CancellationError)}}t.DeferredPromise=D,function(e){e.settled=async function(e){let t;const i=await Promise.all(e.map((e=>e.then((e=>e),(e=>{t||(t=e)})))));if(void 0!==t)throw t;return i},e.withAsyncBody=function(e){return new Promise((async(t,i)=>{try{await e(t,i)}catch(e){i(e)}}))}}(w||(t.Promises=w={}));class L{get value(){return this._value}get error(){return this._error}get isResolved(){return this._isResolved}constructor(e){this._value=void 0,this._error=void 0,this._isResolved=!1,this.promise=e.then((e=>(this._value=e,this._isResolved=!0,e)),(e=>{throw this._error=e,this._isResolved=!0,e}))}requireValue(){if(!this._isResolved)throw new r.BugIndicatingError("Promise is not resolved yet");if(this._error)throw this._error;return this._value}}t.StatefulPromise=L,t.LazyStatefulPromise=class{constructor(e){this._compute=e,this._promise=new h.Lazy((()=>new L(this._compute())))}requireValue(){return this._promise.value.requireValue()}getPromise(){return this._promise.value.promise}get currentValue(){return this._promise.rawValue?.value}},function(e){e[e.Initial=0]="Initial",e[e.DoneOK=1]="DoneOK",e[e.DoneError=2]="DoneError"}(E||(E={}));class R{static fromArray(e){return new R((t=>{t.emitMany(e)}))}static fromPromise(e){return new R((async t=>{t.emitMany(await e)}))}static fromPromises(e){return new R((async t=>{await Promise.all(e.map((async e=>t.emitOne(await e))))}))}static merge(e){return new R((async t=>{await Promise.all(e.map((async e=>{for await(const i of e)t.emitOne(i)})))}))}static{this.EMPTY=R.fromArray([])}constructor(e,t){this._state=E.Initial,this._results=[],this._error=null,this._onReturn=t,this._onStateChanged=new n.Emitter,queueMicrotask((async()=>{const t={emitOne:e=>this.emitOne(e),emitMany:e=>this.emitMany(e),reject:e=>this.reject(e)};try{await Promise.resolve(e(t)),this.resolve()}catch(e){this.reject(e)}finally{t.emitOne=void 0,t.emitMany=void 0,t.reject=void 0}}))}[Symbol.asyncIterator](){let e=0;return{next:async()=>{for(;;){if(this._state===E.DoneError)throw this._error;if(e(this._onReturn?.(),{done:!0,value:void 0})}}static map(e,t){return new R((async i=>{for await(const s of e)i.emitOne(t(s))}))}map(e){return R.map(this,e)}static filter(e,t){return new R((async i=>{for await(const s of e)t(s)&&i.emitOne(s)}))}filter(e){return R.filter(this,e)}static coalesce(e){return R.filter(e,(e=>!!e))}coalesce(){return R.coalesce(this)}static async toPromise(e){const t=[];for await(const i of e)t.push(i);return t}toPromise(){return R.toPromise(this)}emitOne(e){this._state===E.Initial&&(this._results.push(e),this._onStateChanged.fire())}emitMany(e){this._state===E.Initial&&(this._results=this._results.concat(e),this._onStateChanged.fire())}resolve(){this._state===E.Initial&&(this._state=E.DoneOK,this._onStateChanged.fire())}reject(e){this._state===E.Initial&&(this._state=E.DoneError,this._error=e,this._onStateChanged.fire())}}t.AsyncIterableObject=R;class A extends R{constructor(e,t){super(t),this._source=e}cancel(){this._source.cancel()}}t.CancelableAsyncIterableObject=A,t.AsyncIterableSource=class{constructor(e){let t,i;this._deferred=new D,this._asyncIterable=new R((e=>{if(!t)return i&&e.emitMany(i),this._errorFn=t=>e.reject(t),this._emitFn=t=>e.emitOne(t),this._deferred.p;e.reject(t)}),e),this._emitFn=e=>{i||(i=[]),i.push(e)},this._errorFn=e=>{t||(t=e)}}get asyncIterable(){return this._asyncIterable}resolve(){this._deferred.complete()}reject(e){this._errorFn(e),this._deferred.complete()}emitOne(e){this._emitFn(e)}}},8447:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CancellationTokenSource=t.CancellationToken=void 0,t.cancelOnDispose=function(e){const t=new a;return e.add({dispose(){t.cancel()}}),t.token};const s=i(802),r=Object.freeze((function(e,t){const i=setTimeout(e.bind(t),0);return{dispose(){clearTimeout(i)}}}));var n;!function(e){e.isCancellationToken=function(t){return t===e.None||t===e.Cancelled||t instanceof o||!(!t||"object"!=typeof t)&&"boolean"==typeof t.isCancellationRequested&&"function"==typeof t.onCancellationRequested},e.None=Object.freeze({isCancellationRequested:!1,onCancellationRequested:s.Event.None}),e.Cancelled=Object.freeze({isCancellationRequested:!0,onCancellationRequested:r})}(n||(t.CancellationToken=n={}));class o{constructor(){this._isCancelled=!1,this._emitter=null}cancel(){this._isCancelled||(this._isCancelled=!0,this._emitter&&(this._emitter.fire(void 0),this.dispose()))}get isCancellationRequested(){return this._isCancelled}get onCancellationRequested(){return this._isCancelled?r:(this._emitter||(this._emitter=new s.Emitter),this._emitter.event)}dispose(){this._emitter&&(this._emitter.dispose(),this._emitter=null)}}class a{constructor(e){this._token=void 0,this._parentListener=void 0,this._parentListener=e&&e.onCancellationRequested(this.cancel,this)}get token(){return this._token||(this._token=new o),this._token}cancel(){this._token?this._token instanceof o&&this._token.cancel():this._token=n.Cancelled}dispose(e=!1){e&&this.cancel(),this._parentListener?.dispose(),this._token?this._token instanceof o&&this._token.dispose():this._token=n.None}}t.CancellationTokenSource=a},4869:(e,t)=>{var i;Object.defineProperty(t,"__esModule",{value:!0}),t.CharCode=void 0,function(e){e[e.Null=0]="Null",e[e.Backspace=8]="Backspace",e[e.Tab=9]="Tab",e[e.LineFeed=10]="LineFeed",e[e.CarriageReturn=13]="CarriageReturn",e[e.Space=32]="Space",e[e.ExclamationMark=33]="ExclamationMark",e[e.DoubleQuote=34]="DoubleQuote",e[e.Hash=35]="Hash",e[e.DollarSign=36]="DollarSign",e[e.PercentSign=37]="PercentSign",e[e.Ampersand=38]="Ampersand",e[e.SingleQuote=39]="SingleQuote",e[e.OpenParen=40]="OpenParen",e[e.CloseParen=41]="CloseParen",e[e.Asterisk=42]="Asterisk",e[e.Plus=43]="Plus",e[e.Comma=44]="Comma",e[e.Dash=45]="Dash",e[e.Period=46]="Period",e[e.Slash=47]="Slash",e[e.Digit0=48]="Digit0",e[e.Digit1=49]="Digit1",e[e.Digit2=50]="Digit2",e[e.Digit3=51]="Digit3",e[e.Digit4=52]="Digit4",e[e.Digit5=53]="Digit5",e[e.Digit6=54]="Digit6",e[e.Digit7=55]="Digit7",e[e.Digit8=56]="Digit8",e[e.Digit9=57]="Digit9",e[e.Colon=58]="Colon",e[e.Semicolon=59]="Semicolon",e[e.LessThan=60]="LessThan",e[e.Equals=61]="Equals",e[e.GreaterThan=62]="GreaterThan",e[e.QuestionMark=63]="QuestionMark",e[e.AtSign=64]="AtSign",e[e.A=65]="A",e[e.B=66]="B",e[e.C=67]="C",e[e.D=68]="D",e[e.E=69]="E",e[e.F=70]="F",e[e.G=71]="G",e[e.H=72]="H",e[e.I=73]="I",e[e.J=74]="J",e[e.K=75]="K",e[e.L=76]="L",e[e.M=77]="M",e[e.N=78]="N",e[e.O=79]="O",e[e.P=80]="P",e[e.Q=81]="Q",e[e.R=82]="R",e[e.S=83]="S",e[e.T=84]="T",e[e.U=85]="U",e[e.V=86]="V",e[e.W=87]="W",e[e.X=88]="X",e[e.Y=89]="Y",e[e.Z=90]="Z",e[e.OpenSquareBracket=91]="OpenSquareBracket",e[e.Backslash=92]="Backslash",e[e.CloseSquareBracket=93]="CloseSquareBracket",e[e.Caret=94]="Caret",e[e.Underline=95]="Underline",e[e.BackTick=96]="BackTick",e[e.a=97]="a",e[e.b=98]="b",e[e.c=99]="c",e[e.d=100]="d",e[e.e=101]="e",e[e.f=102]="f",e[e.g=103]="g",e[e.h=104]="h",e[e.i=105]="i",e[e.j=106]="j",e[e.k=107]="k",e[e.l=108]="l",e[e.m=109]="m",e[e.n=110]="n",e[e.o=111]="o",e[e.p=112]="p",e[e.q=113]="q",e[e.r=114]="r",e[e.s=115]="s",e[e.t=116]="t",e[e.u=117]="u",e[e.v=118]="v",e[e.w=119]="w",e[e.x=120]="x",e[e.y=121]="y",e[e.z=122]="z",e[e.OpenCurlyBrace=123]="OpenCurlyBrace",e[e.Pipe=124]="Pipe",e[e.CloseCurlyBrace=125]="CloseCurlyBrace",e[e.Tilde=126]="Tilde",e[e.NoBreakSpace=160]="NoBreakSpace",e[e.U_Combining_Grave_Accent=768]="U_Combining_Grave_Accent",e[e.U_Combining_Acute_Accent=769]="U_Combining_Acute_Accent",e[e.U_Combining_Circumflex_Accent=770]="U_Combining_Circumflex_Accent",e[e.U_Combining_Tilde=771]="U_Combining_Tilde",e[e.U_Combining_Macron=772]="U_Combining_Macron",e[e.U_Combining_Overline=773]="U_Combining_Overline",e[e.U_Combining_Breve=774]="U_Combining_Breve",e[e.U_Combining_Dot_Above=775]="U_Combining_Dot_Above",e[e.U_Combining_Diaeresis=776]="U_Combining_Diaeresis",e[e.U_Combining_Hook_Above=777]="U_Combining_Hook_Above",e[e.U_Combining_Ring_Above=778]="U_Combining_Ring_Above",e[e.U_Combining_Double_Acute_Accent=779]="U_Combining_Double_Acute_Accent",e[e.U_Combining_Caron=780]="U_Combining_Caron",e[e.U_Combining_Vertical_Line_Above=781]="U_Combining_Vertical_Line_Above",e[e.U_Combining_Double_Vertical_Line_Above=782]="U_Combining_Double_Vertical_Line_Above",e[e.U_Combining_Double_Grave_Accent=783]="U_Combining_Double_Grave_Accent",e[e.U_Combining_Candrabindu=784]="U_Combining_Candrabindu",e[e.U_Combining_Inverted_Breve=785]="U_Combining_Inverted_Breve",e[e.U_Combining_Turned_Comma_Above=786]="U_Combining_Turned_Comma_Above",e[e.U_Combining_Comma_Above=787]="U_Combining_Comma_Above",e[e.U_Combining_Reversed_Comma_Above=788]="U_Combining_Reversed_Comma_Above",e[e.U_Combining_Comma_Above_Right=789]="U_Combining_Comma_Above_Right",e[e.U_Combining_Grave_Accent_Below=790]="U_Combining_Grave_Accent_Below",e[e.U_Combining_Acute_Accent_Below=791]="U_Combining_Acute_Accent_Below",e[e.U_Combining_Left_Tack_Below=792]="U_Combining_Left_Tack_Below",e[e.U_Combining_Right_Tack_Below=793]="U_Combining_Right_Tack_Below",e[e.U_Combining_Left_Angle_Above=794]="U_Combining_Left_Angle_Above",e[e.U_Combining_Horn=795]="U_Combining_Horn",e[e.U_Combining_Left_Half_Ring_Below=796]="U_Combining_Left_Half_Ring_Below",e[e.U_Combining_Up_Tack_Below=797]="U_Combining_Up_Tack_Below",e[e.U_Combining_Down_Tack_Below=798]="U_Combining_Down_Tack_Below",e[e.U_Combining_Plus_Sign_Below=799]="U_Combining_Plus_Sign_Below",e[e.U_Combining_Minus_Sign_Below=800]="U_Combining_Minus_Sign_Below",e[e.U_Combining_Palatalized_Hook_Below=801]="U_Combining_Palatalized_Hook_Below",e[e.U_Combining_Retroflex_Hook_Below=802]="U_Combining_Retroflex_Hook_Below",e[e.U_Combining_Dot_Below=803]="U_Combining_Dot_Below",e[e.U_Combining_Diaeresis_Below=804]="U_Combining_Diaeresis_Below",e[e.U_Combining_Ring_Below=805]="U_Combining_Ring_Below",e[e.U_Combining_Comma_Below=806]="U_Combining_Comma_Below",e[e.U_Combining_Cedilla=807]="U_Combining_Cedilla",e[e.U_Combining_Ogonek=808]="U_Combining_Ogonek",e[e.U_Combining_Vertical_Line_Below=809]="U_Combining_Vertical_Line_Below",e[e.U_Combining_Bridge_Below=810]="U_Combining_Bridge_Below",e[e.U_Combining_Inverted_Double_Arch_Below=811]="U_Combining_Inverted_Double_Arch_Below",e[e.U_Combining_Caron_Below=812]="U_Combining_Caron_Below",e[e.U_Combining_Circumflex_Accent_Below=813]="U_Combining_Circumflex_Accent_Below",e[e.U_Combining_Breve_Below=814]="U_Combining_Breve_Below",e[e.U_Combining_Inverted_Breve_Below=815]="U_Combining_Inverted_Breve_Below",e[e.U_Combining_Tilde_Below=816]="U_Combining_Tilde_Below",e[e.U_Combining_Macron_Below=817]="U_Combining_Macron_Below",e[e.U_Combining_Low_Line=818]="U_Combining_Low_Line",e[e.U_Combining_Double_Low_Line=819]="U_Combining_Double_Low_Line",e[e.U_Combining_Tilde_Overlay=820]="U_Combining_Tilde_Overlay",e[e.U_Combining_Short_Stroke_Overlay=821]="U_Combining_Short_Stroke_Overlay",e[e.U_Combining_Long_Stroke_Overlay=822]="U_Combining_Long_Stroke_Overlay",e[e.U_Combining_Short_Solidus_Overlay=823]="U_Combining_Short_Solidus_Overlay",e[e.U_Combining_Long_Solidus_Overlay=824]="U_Combining_Long_Solidus_Overlay",e[e.U_Combining_Right_Half_Ring_Below=825]="U_Combining_Right_Half_Ring_Below",e[e.U_Combining_Inverted_Bridge_Below=826]="U_Combining_Inverted_Bridge_Below",e[e.U_Combining_Square_Below=827]="U_Combining_Square_Below",e[e.U_Combining_Seagull_Below=828]="U_Combining_Seagull_Below",e[e.U_Combining_X_Above=829]="U_Combining_X_Above",e[e.U_Combining_Vertical_Tilde=830]="U_Combining_Vertical_Tilde",e[e.U_Combining_Double_Overline=831]="U_Combining_Double_Overline",e[e.U_Combining_Grave_Tone_Mark=832]="U_Combining_Grave_Tone_Mark",e[e.U_Combining_Acute_Tone_Mark=833]="U_Combining_Acute_Tone_Mark",e[e.U_Combining_Greek_Perispomeni=834]="U_Combining_Greek_Perispomeni",e[e.U_Combining_Greek_Koronis=835]="U_Combining_Greek_Koronis",e[e.U_Combining_Greek_Dialytika_Tonos=836]="U_Combining_Greek_Dialytika_Tonos",e[e.U_Combining_Greek_Ypogegrammeni=837]="U_Combining_Greek_Ypogegrammeni",e[e.U_Combining_Bridge_Above=838]="U_Combining_Bridge_Above",e[e.U_Combining_Equals_Sign_Below=839]="U_Combining_Equals_Sign_Below",e[e.U_Combining_Double_Vertical_Line_Below=840]="U_Combining_Double_Vertical_Line_Below",e[e.U_Combining_Left_Angle_Below=841]="U_Combining_Left_Angle_Below",e[e.U_Combining_Not_Tilde_Above=842]="U_Combining_Not_Tilde_Above",e[e.U_Combining_Homothetic_Above=843]="U_Combining_Homothetic_Above",e[e.U_Combining_Almost_Equal_To_Above=844]="U_Combining_Almost_Equal_To_Above",e[e.U_Combining_Left_Right_Arrow_Below=845]="U_Combining_Left_Right_Arrow_Below",e[e.U_Combining_Upwards_Arrow_Below=846]="U_Combining_Upwards_Arrow_Below",e[e.U_Combining_Grapheme_Joiner=847]="U_Combining_Grapheme_Joiner",e[e.U_Combining_Right_Arrowhead_Above=848]="U_Combining_Right_Arrowhead_Above",e[e.U_Combining_Left_Half_Ring_Above=849]="U_Combining_Left_Half_Ring_Above",e[e.U_Combining_Fermata=850]="U_Combining_Fermata",e[e.U_Combining_X_Below=851]="U_Combining_X_Below",e[e.U_Combining_Left_Arrowhead_Below=852]="U_Combining_Left_Arrowhead_Below",e[e.U_Combining_Right_Arrowhead_Below=853]="U_Combining_Right_Arrowhead_Below",e[e.U_Combining_Right_Arrowhead_And_Up_Arrowhead_Below=854]="U_Combining_Right_Arrowhead_And_Up_Arrowhead_Below",e[e.U_Combining_Right_Half_Ring_Above=855]="U_Combining_Right_Half_Ring_Above",e[e.U_Combining_Dot_Above_Right=856]="U_Combining_Dot_Above_Right",e[e.U_Combining_Asterisk_Below=857]="U_Combining_Asterisk_Below",e[e.U_Combining_Double_Ring_Below=858]="U_Combining_Double_Ring_Below",e[e.U_Combining_Zigzag_Above=859]="U_Combining_Zigzag_Above",e[e.U_Combining_Double_Breve_Below=860]="U_Combining_Double_Breve_Below",e[e.U_Combining_Double_Breve=861]="U_Combining_Double_Breve",e[e.U_Combining_Double_Macron=862]="U_Combining_Double_Macron",e[e.U_Combining_Double_Macron_Below=863]="U_Combining_Double_Macron_Below",e[e.U_Combining_Double_Tilde=864]="U_Combining_Double_Tilde",e[e.U_Combining_Double_Inverted_Breve=865]="U_Combining_Double_Inverted_Breve",e[e.U_Combining_Double_Rightwards_Arrow_Below=866]="U_Combining_Double_Rightwards_Arrow_Below",e[e.U_Combining_Latin_Small_Letter_A=867]="U_Combining_Latin_Small_Letter_A",e[e.U_Combining_Latin_Small_Letter_E=868]="U_Combining_Latin_Small_Letter_E",e[e.U_Combining_Latin_Small_Letter_I=869]="U_Combining_Latin_Small_Letter_I",e[e.U_Combining_Latin_Small_Letter_O=870]="U_Combining_Latin_Small_Letter_O",e[e.U_Combining_Latin_Small_Letter_U=871]="U_Combining_Latin_Small_Letter_U",e[e.U_Combining_Latin_Small_Letter_C=872]="U_Combining_Latin_Small_Letter_C",e[e.U_Combining_Latin_Small_Letter_D=873]="U_Combining_Latin_Small_Letter_D",e[e.U_Combining_Latin_Small_Letter_H=874]="U_Combining_Latin_Small_Letter_H",e[e.U_Combining_Latin_Small_Letter_M=875]="U_Combining_Latin_Small_Letter_M",e[e.U_Combining_Latin_Small_Letter_R=876]="U_Combining_Latin_Small_Letter_R",e[e.U_Combining_Latin_Small_Letter_T=877]="U_Combining_Latin_Small_Letter_T",e[e.U_Combining_Latin_Small_Letter_V=878]="U_Combining_Latin_Small_Letter_V",e[e.U_Combining_Latin_Small_Letter_X=879]="U_Combining_Latin_Small_Letter_X",e[e.LINE_SEPARATOR=8232]="LINE_SEPARATOR",e[e.PARAGRAPH_SEPARATOR=8233]="PARAGRAPH_SEPARATOR",e[e.NEXT_LINE=133]="NEXT_LINE",e[e.U_CIRCUMFLEX=94]="U_CIRCUMFLEX",e[e.U_GRAVE_ACCENT=96]="U_GRAVE_ACCENT",e[e.U_DIAERESIS=168]="U_DIAERESIS",e[e.U_MACRON=175]="U_MACRON",e[e.U_ACUTE_ACCENT=180]="U_ACUTE_ACCENT",e[e.U_CEDILLA=184]="U_CEDILLA",e[e.U_MODIFIER_LETTER_LEFT_ARROWHEAD=706]="U_MODIFIER_LETTER_LEFT_ARROWHEAD",e[e.U_MODIFIER_LETTER_RIGHT_ARROWHEAD=707]="U_MODIFIER_LETTER_RIGHT_ARROWHEAD",e[e.U_MODIFIER_LETTER_UP_ARROWHEAD=708]="U_MODIFIER_LETTER_UP_ARROWHEAD",e[e.U_MODIFIER_LETTER_DOWN_ARROWHEAD=709]="U_MODIFIER_LETTER_DOWN_ARROWHEAD",e[e.U_MODIFIER_LETTER_CENTRED_RIGHT_HALF_RING=722]="U_MODIFIER_LETTER_CENTRED_RIGHT_HALF_RING",e[e.U_MODIFIER_LETTER_CENTRED_LEFT_HALF_RING=723]="U_MODIFIER_LETTER_CENTRED_LEFT_HALF_RING",e[e.U_MODIFIER_LETTER_UP_TACK=724]="U_MODIFIER_LETTER_UP_TACK",e[e.U_MODIFIER_LETTER_DOWN_TACK=725]="U_MODIFIER_LETTER_DOWN_TACK",e[e.U_MODIFIER_LETTER_PLUS_SIGN=726]="U_MODIFIER_LETTER_PLUS_SIGN",e[e.U_MODIFIER_LETTER_MINUS_SIGN=727]="U_MODIFIER_LETTER_MINUS_SIGN",e[e.U_BREVE=728]="U_BREVE",e[e.U_DOT_ABOVE=729]="U_DOT_ABOVE",e[e.U_RING_ABOVE=730]="U_RING_ABOVE",e[e.U_OGONEK=731]="U_OGONEK",e[e.U_SMALL_TILDE=732]="U_SMALL_TILDE",e[e.U_DOUBLE_ACUTE_ACCENT=733]="U_DOUBLE_ACUTE_ACCENT",e[e.U_MODIFIER_LETTER_RHOTIC_HOOK=734]="U_MODIFIER_LETTER_RHOTIC_HOOK",e[e.U_MODIFIER_LETTER_CROSS_ACCENT=735]="U_MODIFIER_LETTER_CROSS_ACCENT",e[e.U_MODIFIER_LETTER_EXTRA_HIGH_TONE_BAR=741]="U_MODIFIER_LETTER_EXTRA_HIGH_TONE_BAR",e[e.U_MODIFIER_LETTER_HIGH_TONE_BAR=742]="U_MODIFIER_LETTER_HIGH_TONE_BAR",e[e.U_MODIFIER_LETTER_MID_TONE_BAR=743]="U_MODIFIER_LETTER_MID_TONE_BAR",e[e.U_MODIFIER_LETTER_LOW_TONE_BAR=744]="U_MODIFIER_LETTER_LOW_TONE_BAR",e[e.U_MODIFIER_LETTER_EXTRA_LOW_TONE_BAR=745]="U_MODIFIER_LETTER_EXTRA_LOW_TONE_BAR",e[e.U_MODIFIER_LETTER_YIN_DEPARTING_TONE_MARK=746]="U_MODIFIER_LETTER_YIN_DEPARTING_TONE_MARK",e[e.U_MODIFIER_LETTER_YANG_DEPARTING_TONE_MARK=747]="U_MODIFIER_LETTER_YANG_DEPARTING_TONE_MARK",e[e.U_MODIFIER_LETTER_UNASPIRATED=749]="U_MODIFIER_LETTER_UNASPIRATED",e[e.U_MODIFIER_LETTER_LOW_DOWN_ARROWHEAD=751]="U_MODIFIER_LETTER_LOW_DOWN_ARROWHEAD",e[e.U_MODIFIER_LETTER_LOW_UP_ARROWHEAD=752]="U_MODIFIER_LETTER_LOW_UP_ARROWHEAD",e[e.U_MODIFIER_LETTER_LOW_LEFT_ARROWHEAD=753]="U_MODIFIER_LETTER_LOW_LEFT_ARROWHEAD",e[e.U_MODIFIER_LETTER_LOW_RIGHT_ARROWHEAD=754]="U_MODIFIER_LETTER_LOW_RIGHT_ARROWHEAD",e[e.U_MODIFIER_LETTER_LOW_RING=755]="U_MODIFIER_LETTER_LOW_RING",e[e.U_MODIFIER_LETTER_MIDDLE_GRAVE_ACCENT=756]="U_MODIFIER_LETTER_MIDDLE_GRAVE_ACCENT",e[e.U_MODIFIER_LETTER_MIDDLE_DOUBLE_GRAVE_ACCENT=757]="U_MODIFIER_LETTER_MIDDLE_DOUBLE_GRAVE_ACCENT",e[e.U_MODIFIER_LETTER_MIDDLE_DOUBLE_ACUTE_ACCENT=758]="U_MODIFIER_LETTER_MIDDLE_DOUBLE_ACUTE_ACCENT",e[e.U_MODIFIER_LETTER_LOW_TILDE=759]="U_MODIFIER_LETTER_LOW_TILDE",e[e.U_MODIFIER_LETTER_RAISED_COLON=760]="U_MODIFIER_LETTER_RAISED_COLON",e[e.U_MODIFIER_LETTER_BEGIN_HIGH_TONE=761]="U_MODIFIER_LETTER_BEGIN_HIGH_TONE",e[e.U_MODIFIER_LETTER_END_HIGH_TONE=762]="U_MODIFIER_LETTER_END_HIGH_TONE",e[e.U_MODIFIER_LETTER_BEGIN_LOW_TONE=763]="U_MODIFIER_LETTER_BEGIN_LOW_TONE",e[e.U_MODIFIER_LETTER_END_LOW_TONE=764]="U_MODIFIER_LETTER_END_LOW_TONE",e[e.U_MODIFIER_LETTER_SHELF=765]="U_MODIFIER_LETTER_SHELF",e[e.U_MODIFIER_LETTER_OPEN_SHELF=766]="U_MODIFIER_LETTER_OPEN_SHELF",e[e.U_MODIFIER_LETTER_LOW_LEFT_ARROW=767]="U_MODIFIER_LETTER_LOW_LEFT_ARROW",e[e.U_GREEK_LOWER_NUMERAL_SIGN=885]="U_GREEK_LOWER_NUMERAL_SIGN",e[e.U_GREEK_TONOS=900]="U_GREEK_TONOS",e[e.U_GREEK_DIALYTIKA_TONOS=901]="U_GREEK_DIALYTIKA_TONOS",e[e.U_GREEK_KORONIS=8125]="U_GREEK_KORONIS",e[e.U_GREEK_PSILI=8127]="U_GREEK_PSILI",e[e.U_GREEK_PERISPOMENI=8128]="U_GREEK_PERISPOMENI",e[e.U_GREEK_DIALYTIKA_AND_PERISPOMENI=8129]="U_GREEK_DIALYTIKA_AND_PERISPOMENI",e[e.U_GREEK_PSILI_AND_VARIA=8141]="U_GREEK_PSILI_AND_VARIA",e[e.U_GREEK_PSILI_AND_OXIA=8142]="U_GREEK_PSILI_AND_OXIA",e[e.U_GREEK_PSILI_AND_PERISPOMENI=8143]="U_GREEK_PSILI_AND_PERISPOMENI",e[e.U_GREEK_DASIA_AND_VARIA=8157]="U_GREEK_DASIA_AND_VARIA",e[e.U_GREEK_DASIA_AND_OXIA=8158]="U_GREEK_DASIA_AND_OXIA",e[e.U_GREEK_DASIA_AND_PERISPOMENI=8159]="U_GREEK_DASIA_AND_PERISPOMENI",e[e.U_GREEK_DIALYTIKA_AND_VARIA=8173]="U_GREEK_DIALYTIKA_AND_VARIA",e[e.U_GREEK_DIALYTIKA_AND_OXIA=8174]="U_GREEK_DIALYTIKA_AND_OXIA",e[e.U_GREEK_VARIA=8175]="U_GREEK_VARIA",e[e.U_GREEK_OXIA=8189]="U_GREEK_OXIA",e[e.U_GREEK_DASIA=8190]="U_GREEK_DASIA",e[e.U_IDEOGRAPHIC_FULL_STOP=12290]="U_IDEOGRAPHIC_FULL_STOP",e[e.U_LEFT_CORNER_BRACKET=12300]="U_LEFT_CORNER_BRACKET",e[e.U_RIGHT_CORNER_BRACKET=12301]="U_RIGHT_CORNER_BRACKET",e[e.U_LEFT_BLACK_LENTICULAR_BRACKET=12304]="U_LEFT_BLACK_LENTICULAR_BRACKET",e[e.U_RIGHT_BLACK_LENTICULAR_BRACKET=12305]="U_RIGHT_BLACK_LENTICULAR_BRACKET",e[e.U_OVERLINE=8254]="U_OVERLINE",e[e.UTF8_BOM=65279]="UTF8_BOM",e[e.U_FULLWIDTH_SEMICOLON=65307]="U_FULLWIDTH_SEMICOLON",e[e.U_FULLWIDTH_COMMA=65292]="U_FULLWIDTH_COMMA"}(i||(t.CharCode=i={}))},9087:(e,t)=>{var i;Object.defineProperty(t,"__esModule",{value:!0}),t.SetWithKey=void 0,t.groupBy=function(e,t){const i=Object.create(null);for(const s of e){const e=t(s);let r=i[e];r||(r=i[e]=[]),r.push(s)}return i},t.diffSets=function(e,t){const i=[],s=[];for(const s of e)t.has(s)||i.push(s);for(const i of t)e.has(i)||s.push(i);return{removed:i,added:s}},t.diffMaps=function(e,t){const i=[],s=[];for(const[s,r]of e)t.has(s)||i.push(r);for(const[i,r]of t)e.has(i)||s.push(r);return{removed:i,added:s}},t.intersection=function(e,t){const i=new Set;for(const s of t)e.has(s)&&i.add(s);return i};class s{static{i=Symbol.toStringTag}constructor(e,t){this.toKey=t,this._map=new Map,this[i]="SetWithKey";for(const t of e)this.add(t)}get size(){return this._map.size}add(e){const t=this.toKey(e);return this._map.set(t,e),this}delete(e){return this._map.delete(this.toKey(e))}has(e){return this._map.has(this.toKey(e))}*entries(){for(const e of this._map.values())yield[e,e]}keys(){return this.values()}*values(){for(const e of this._map.values())yield e}clear(){this._map.clear()}forEach(e,t){this._map.forEach((i=>e.call(t,i,i,this)))}[Symbol.iterator](){return this.values()}}t.SetWithKey=s},4838:(e,t)=>{function i(e){return(t,i,s)=>{let r=null,n=null;if("function"==typeof s.value?(r="value",n=s.value):"function"==typeof s.get&&(r="get",n=s.get),!n)throw new Error("not supported");s[r]=e(n,i)}}Object.defineProperty(t,"__esModule",{value:!0}),t.memoize=function(e,t,i){let s=null,r=null;if("function"==typeof i.value?(s="value",r=i.value,0!==r.length&&console.warn("Memoize should only be used in functions with zero parameters")):"function"==typeof i.get&&(s="get",r=i.get),!r)throw new Error("not supported");const n=`$memoize$${t}`;i[s]=function(...e){return this.hasOwnProperty(n)||Object.defineProperty(this,n,{configurable:!1,enumerable:!1,writable:!1,value:r.apply(this,e)}),this[n]}},t.debounce=function(e,t,s){return i(((i,r)=>{const n=`$debounce$${r}`,o=`$debounce$result$${r}`;return function(...r){this[o]||(this[o]=s?s():void 0),clearTimeout(this[n]),t&&(this[o]=t(this[o],...r),r=[this[o]]),this[n]=setTimeout((()=>{i.apply(this,r),this[o]=s?s():void 0}),e)}}))},t.throttle=function(e,t,s){return i(((i,r)=>{const n=`$throttle$timer$${r}`,o=`$throttle$result$${r}`,a=`$throttle$lastRun$${r}`,l=`$throttle$pending$${r}`;return function(...r){if(this[o]||(this[o]=s?s():void 0),null!==this[a]&&void 0!==this[a]||(this[a]=-Number.MAX_VALUE),t&&(this[o]=t(this[o],...r)),this[l])return;const h=this[a]+e;h<=Date.now()?(this[a]=Date.now(),i.apply(this,[this[o]]),this[o]=s?s():void 0):(this[l]=!0,this[n]=setTimeout((()=>{this[l]=!1,this[a]=Date.now(),i.apply(this,[this[o]]),this[o]=s?s():void 0}),h-Date.now()))}}))}},9807:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BugIndicatingError=t.ErrorNoTelemetry=t.ExpectedError=t.NotSupportedError=t.NotImplementedError=t.ReadonlyError=t.CancellationError=t.errorHandler=t.ErrorHandler=void 0,t.setUnexpectedErrorHandler=function(e){t.errorHandler.setUnexpectedErrorHandler(e)},t.isSigPipeError=function(e){if(!e||"object"!=typeof e)return!1;const t=e;return"EPIPE"===t.code&&"WRITE"===t.syscall?.toUpperCase()},t.onUnexpectedError=function(e){r(e)||t.errorHandler.onUnexpectedError(e)},t.onUnexpectedExternalError=function(e){r(e)||t.errorHandler.onUnexpectedExternalError(e)},t.transformErrorForSerialization=function(e){if(e instanceof Error){const{name:t,message:i}=e;return{$isError:!0,name:t,message:i,stack:e.stacktrace||e.stack,noTelemetry:c.isErrorNoTelemetry(e)}}return e},t.transformErrorFromSerialization=function(e){let t;return e.noTelemetry?t=new c:(t=new Error,t.name=e.name),t.message=e.message,t.stack=e.stack,t},t.isCancellationError=r,t.canceled=function(){const e=new Error(s);return e.name=e.message,e},t.illegalArgument=function(e){return e?new Error(`Illegal argument: ${e}`):new Error("Illegal argument")},t.illegalState=function(e){return e?new Error(`Illegal state: ${e}`):new Error("Illegal state")},t.getErrorMessage=function(e){return e?e.message?e.message:e.stack?e.stack.split("\n")[0]:String(e):"Error"};class i{constructor(){this.listeners=[],this.unexpectedErrorHandler=function(e){setTimeout((()=>{if(e.stack){if(c.isErrorNoTelemetry(e))throw new c(e.message+"\n\n"+e.stack);throw new Error(e.message+"\n\n"+e.stack)}throw e}),0)}}addListener(e){return this.listeners.push(e),()=>{this._removeListener(e)}}emit(e){this.listeners.forEach((t=>{t(e)}))}_removeListener(e){this.listeners.splice(this.listeners.indexOf(e),1)}setUnexpectedErrorHandler(e){this.unexpectedErrorHandler=e}getUnexpectedErrorHandler(){return this.unexpectedErrorHandler}onUnexpectedError(e){this.unexpectedErrorHandler(e),this.emit(e)}onUnexpectedExternalError(e){this.unexpectedErrorHandler(e)}}t.ErrorHandler=i,t.errorHandler=new i;const s="Canceled";function r(e){return e instanceof n||e instanceof Error&&e.name===s&&e.message===s}class n extends Error{constructor(){super(s),this.name=this.message}}t.CancellationError=n;class o extends TypeError{constructor(e){super(e?`${e} is read-only and cannot be changed`:"Cannot change read-only property")}}t.ReadonlyError=o;class a extends Error{constructor(e){super("NotImplemented"),e&&(this.message=e)}}t.NotImplementedError=a;class l extends Error{constructor(e){super("NotSupported"),e&&(this.message=e)}}t.NotSupportedError=l;class h extends Error{constructor(){super(...arguments),this.isExpected=!0}}t.ExpectedError=h;class c extends Error{constructor(e){super(e),this.name="CodeExpectedError"}static fromError(e){if(e instanceof c)return e;const t=new c;return t.message=e.message,t.stack=e.stack,t}static isErrorNoTelemetry(e){return"CodeExpectedError"===e.name}}t.ErrorNoTelemetry=c;class d extends Error{constructor(e){super(e||"An unexpected bug occurred."),Object.setPrototypeOf(this,d.prototype)}}t.BugIndicatingError=d},802:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ValueWithChangeEvent=t.Relay=t.EventBufferer=t.DynamicListEventMultiplexer=t.EventMultiplexer=t.MicrotaskEmitter=t.DebounceEmitter=t.PauseableEmitter=t.AsyncEmitter=t.createEventDeliveryQueue=t.Emitter=t.ListenerRefusalError=t.ListenerLeakError=t.EventProfiling=t.Event=void 0,t.setGlobalLeakWarningThreshold=function(e){const t=c;return c=e,{dispose(){c=t}}};const s=i(9807),r=i(8841),n=i(7150),o=i(6317),a=i(9725);var l;!function(e){function t(e){return(t,i=null,s)=>{let r,n=!1;return r=e((e=>{if(!n)return r?r.dispose():n=!0,t.call(i,e)}),null,s),n&&r.dispose(),r}}function i(e,t,i){return r(((i,s=null,r)=>e((e=>i.call(s,t(e))),null,r)),i)}function s(e,t,i){return r(((i,s=null,r)=>e((e=>t(e)&&i.call(s,e)),null,r)),i)}function r(e,t){let i;const s=new m({onWillAddFirstListener(){i=e(s.fire,s)},onDidRemoveLastListener(){i?.dispose()}});return t?.add(s),s.event}function o(e,t,i=100,s=!1,r=!1,n,o){let a,l,h,c,d=0;const u=new m({leakWarningThreshold:n,onWillAddFirstListener(){a=e((e=>{d++,l=t(l,e),s&&!h&&(u.fire(l),l=void 0),c=()=>{const e=l;l=void 0,h=void 0,(!s||d>1)&&u.fire(e),d=0},"number"==typeof i?(clearTimeout(h),h=setTimeout(c,i)):void 0===h&&(h=0,queueMicrotask(c))}))},onWillRemoveListener(){r&&d>0&&c?.()},onDidRemoveLastListener(){c=void 0,a.dispose()}});return o?.add(u),u.event}e.None=()=>n.Disposable.None,e.defer=function(e,t){return o(e,(()=>{}),0,void 0,!0,void 0,t)},e.once=t,e.map=i,e.forEach=function(e,t,i){return r(((i,s=null,r)=>e((e=>{t(e),i.call(s,e)}),null,r)),i)},e.filter=s,e.signal=function(e){return e},e.any=function(...e){return(t,i=null,s)=>{return r=(0,n.combinedDisposable)(...e.map((e=>e((e=>t.call(i,e)))))),(o=s)instanceof Array?o.push(r):o&&o.add(r),r;var r,o}},e.reduce=function(e,t,s,r){let n=s;return i(e,(e=>(n=t(n,e),n)),r)},e.debounce=o,e.accumulate=function(t,i=0,s){return e.debounce(t,((e,t)=>e?(e.push(t),e):[t]),i,void 0,!0,void 0,s)},e.latch=function(e,t=(e,t)=>e===t,i){let r,n=!0;return s(e,(e=>{const i=n||!t(e,r);return n=!1,r=e,i}),i)},e.split=function(t,i,s){return[e.filter(t,i,s),e.filter(t,(e=>!i(e)),s)]},e.buffer=function(e,t=!1,i=[],s){let r=i.slice(),n=e((e=>{r?r.push(e):a.fire(e)}));s&&s.add(n);const o=()=>{r?.forEach((e=>a.fire(e))),r=null},a=new m({onWillAddFirstListener(){n||(n=e((e=>a.fire(e))),s&&s.add(n))},onDidAddFirstListener(){r&&(t?setTimeout(o):o())},onDidRemoveLastListener(){n&&n.dispose(),n=null}});return s&&s.add(a),a.event},e.chain=function(e,t){return(i,s,r)=>{const n=t(new l);return e((function(e){const t=n.evaluate(e);t!==a&&i.call(s,t)}),void 0,r)}};const a=Symbol("HaltChainable");class l{constructor(){this.steps=[]}map(e){return this.steps.push(e),this}forEach(e){return this.steps.push((t=>(e(t),t))),this}filter(e){return this.steps.push((t=>e(t)?t:a)),this}reduce(e,t){let i=t;return this.steps.push((t=>(i=e(i,t),i))),this}latch(e=(e,t)=>e===t){let t,i=!0;return this.steps.push((s=>{const r=i||!e(s,t);return i=!1,t=s,r?s:a})),this}evaluate(e){for(const t of this.steps)if((e=t(e))===a)break;return e}}e.fromNodeEventEmitter=function(e,t,i=e=>e){const s=(...e)=>r.fire(i(...e)),r=new m({onWillAddFirstListener:()=>e.on(t,s),onDidRemoveLastListener:()=>e.removeListener(t,s)});return r.event},e.fromDOMEventEmitter=function(e,t,i=e=>e){const s=(...e)=>r.fire(i(...e)),r=new m({onWillAddFirstListener:()=>e.addEventListener(t,s),onDidRemoveLastListener:()=>e.removeEventListener(t,s)});return r.event},e.toPromise=function(e){return new Promise((i=>t(e)(i)))},e.fromPromise=function(e){const t=new m;return e.then((e=>{t.fire(e)}),(()=>{t.fire(void 0)})).finally((()=>{t.dispose()})),t.event},e.forward=function(e,t){return e((e=>t.fire(e)))},e.runAndSubscribe=function(e,t,i){return t(i),e((e=>t(e)))};class h{constructor(e,t){this._observable=e,this._counter=0,this._hasChanged=!1;const i={onWillAddFirstListener:()=>{e.addObserver(this)},onDidRemoveLastListener:()=>{e.removeObserver(this)}};this.emitter=new m(i),t&&t.add(this.emitter)}beginUpdate(e){this._counter++}handlePossibleChange(e){}handleChange(e,t){this._hasChanged=!0}endUpdate(e){this._counter--,0===this._counter&&(this._observable.reportChanges(),this._hasChanged&&(this._hasChanged=!1,this.emitter.fire(this._observable.get())))}}e.fromObservable=function(e,t){return new h(e,t).emitter.event},e.fromObservableLight=function(e){return(t,i,s)=>{let r=0,o=!1;const a={beginUpdate(){r++},endUpdate(){r--,0===r&&(e.reportChanges(),o&&(o=!1,t.call(i)))},handlePossibleChange(){},handleChange(){o=!0}};e.addObserver(a),e.reportChanges();const l={dispose(){e.removeObserver(a)}};return s instanceof n.DisposableStore?s.add(l):Array.isArray(s)&&s.push(l),l}}}(l||(t.Event=l={}));class h{static{this.all=new Set}static{this._idPool=0}constructor(e){this.listenerCount=0,this.invocationCount=0,this.elapsedOverall=0,this.durations=[],this.name=`${e}_${h._idPool++}`,h.all.add(this)}start(e){this._stopWatch=new a.StopWatch,this.listenerCount=e}stop(){if(this._stopWatch){const e=this._stopWatch.elapsed();this.durations.push(e),this.elapsedOverall+=e,this.invocationCount+=1,this._stopWatch=void 0}}}t.EventProfiling=h;let c=-1;class d{static{this._idPool=1}constructor(e,t,i=(d._idPool++).toString(16).padStart(3,"0")){this._errorHandler=e,this.threshold=t,this.name=i,this._warnCountdown=0}dispose(){this._stacks?.clear()}check(e,t){const i=this.threshold;if(i<=0||t{const t=this._stacks.get(e.value)||0;this._stacks.set(e.value,t-1)}}getMostFrequentStack(){if(!this._stacks)return;let e,t=0;for(const[i,s]of this._stacks)(!e||t0||this._options?.leakWarningThreshold?new d(e?.onListenerError??s.onUnexpectedError,this._options?.leakWarningThreshold??c):void 0,this._perfMon=this._options?._profName?new h(this._options._profName):void 0,this._deliveryQueue=this._options?.deliveryQueue}dispose(){this._disposed||(this._disposed=!0,this._deliveryQueue?.current===this&&this._deliveryQueue.reset(),this._listeners&&(this._listeners=void 0,this._size=0),this._options?.onDidRemoveLastListener?.(),this._leakageMon?.dispose())}get event(){return this._event??=(e,t,i)=>{if(this._leakageMon&&this._size>this._leakageMon.threshold**2){const e=`[${this._leakageMon.name}] REFUSES to accept new listeners because it exceeded its threshold by far (${this._size} vs ${this._leakageMon.threshold})`;console.warn(e);const t=this._leakageMon.getMostFrequentStack()??["UNKNOWN stack",-1],i=new f(`${e}. HINT: Stack shows most frequent listener (${t[1]}-times)`,t[0]);return(this._options?.onListenerError||s.onUnexpectedError)(i),n.Disposable.None}if(this._disposed)return n.Disposable.None;t&&(e=e.bind(t));const r=new g(e);let o;this._leakageMon&&this._size>=Math.ceil(.2*this._leakageMon.threshold)&&(r.stack=u.create(),o=this._leakageMon.check(r.stack,this._size+1)),this._listeners?this._listeners instanceof g?(this._deliveryQueue??=new v,this._listeners=[this._listeners,r]):this._listeners.push(r):(this._options?.onWillAddFirstListener?.(this),this._listeners=r,this._options?.onDidAddFirstListener?.(this)),this._size++;const a=(0,n.toDisposable)((()=>{o?.(),this._removeListener(r)}));return i instanceof n.DisposableStore?i.add(a):Array.isArray(i)&&i.push(a),a},this._event}_removeListener(e){if(this._options?.onWillRemoveListener?.(this),!this._listeners)return;if(1===this._size)return this._listeners=void 0,this._options?.onDidRemoveLastListener?.(this),void(this._size=0);const t=this._listeners,i=t.indexOf(e);if(-1===i)throw console.log("disposed?",this._disposed),console.log("size?",this._size),console.log("arr?",JSON.stringify(this._listeners)),new Error("Attempted to dispose unknown listener");this._size--,t[i]=void 0;const s=this._deliveryQueue.current===this;if(2*this._size<=t.length){let e=0;for(let i=0;i0}}t.Emitter=m,t.createEventDeliveryQueue=()=>new v;class v{constructor(){this.i=-1,this.end=0}enqueue(e,t,i){this.i=0,this.end=i,this.current=e,this.value=t}reset(){this.i=this.end,this.current=void 0,this.value=void 0}}t.AsyncEmitter=class extends m{async fireAsync(e,t,i){if(this._listeners)for(this._asyncDeliveryQueue||(this._asyncDeliveryQueue=new o.LinkedList),((e,t)=>{if(e instanceof g)t(e);else for(let i=0;ithis._asyncDeliveryQueue.push([t.value,e])));this._asyncDeliveryQueue.size>0&&!t.isCancellationRequested;){const[e,r]=this._asyncDeliveryQueue.shift(),n=[],o={...r,token:t,waitUntil:t=>{if(Object.isFrozen(n))throw new Error("waitUntil can NOT be called asynchronous");i&&(t=i(t,e)),n.push(t)}};try{e(o)}catch(e){(0,s.onUnexpectedError)(e);continue}Object.freeze(n),await Promise.allSettled(n).then((e=>{for(const t of e)"rejected"===t.status&&(0,s.onUnexpectedError)(t.reason)}))}}};class S extends m{get isPaused(){return 0!==this._isPaused}constructor(e){super(e),this._isPaused=0,this._eventQueue=new o.LinkedList,this._mergeFn=e?.merge}pause(){this._isPaused++}resume(){if(0!==this._isPaused&&0==--this._isPaused)if(this._mergeFn){if(this._eventQueue.size>0){const e=Array.from(this._eventQueue);this._eventQueue.clear(),super.fire(this._mergeFn(e))}}else for(;!this._isPaused&&0!==this._eventQueue.size;)super.fire(this._eventQueue.shift())}fire(e){this._size&&(0!==this._isPaused?this._eventQueue.push(e):super.fire(e))}}t.PauseableEmitter=S,t.DebounceEmitter=class extends S{constructor(e){super(e),this._delay=e.delay??100}fire(e){this._handle||(this.pause(),this._handle=setTimeout((()=>{this._handle=void 0,this.resume()}),this._delay)),super.fire(e)}},t.MicrotaskEmitter=class extends m{constructor(e){super(e),this._queuedEvents=[],this._mergeFn=e?.merge}fire(e){this.hasListeners()&&(this._queuedEvents.push(e),1===this._queuedEvents.length&&queueMicrotask((()=>{this._mergeFn?super.fire(this._mergeFn(this._queuedEvents)):this._queuedEvents.forEach((e=>super.fire(e))),this._queuedEvents=[]})))}};class b{constructor(){this.hasListeners=!1,this.events=[],this.emitter=new m({onWillAddFirstListener:()=>this.onFirstListenerAdd(),onDidRemoveLastListener:()=>this.onLastListenerRemove()})}get event(){return this.emitter.event}add(e){const t={event:e,listener:null};return this.events.push(t),this.hasListeners&&this.hook(t),(0,n.toDisposable)((0,r.createSingleCallFunction)((()=>{this.hasListeners&&this.unhook(t);const e=this.events.indexOf(t);this.events.splice(e,1)})))}onFirstListenerAdd(){this.hasListeners=!0,this.events.forEach((e=>this.hook(e)))}onLastListenerRemove(){this.hasListeners=!1,this.events.forEach((e=>this.unhook(e)))}hook(e){e.listener=e.event((e=>this.emitter.fire(e)))}unhook(e){e.listener?.dispose(),e.listener=null}dispose(){this.emitter.dispose();for(const e of this.events)e.listener?.dispose();this.events=[]}}t.EventMultiplexer=b,t.DynamicListEventMultiplexer=class{constructor(e,t,i,s){this._store=new n.DisposableStore;const r=this._store.add(new b),o=this._store.add(new n.DisposableMap);function a(e){o.set(e,r.add(s(e)))}for(const t of e)a(t);this._store.add(t((e=>{a(e)}))),this._store.add(i((e=>{o.deleteAndDispose(e)}))),this.event=r.event}dispose(){this._store.dispose()}},t.EventBufferer=class{constructor(){this.data=[]}wrapEvent(e,t,i){return(s,r,n)=>e((e=>{const n=this.data[this.data.length-1];if(!t)return void(n?n.buffers.push((()=>s.call(r,e))):s.call(r,e));const o=n;o?(o.items??=[],o.items.push(e),0===o.buffers.length&&n.buffers.push((()=>{o.reducedResult??=i?o.items.reduce(t,i):o.items.reduce(t),s.call(r,o.reducedResult)}))):s.call(r,t(i,e))}),void 0,n)}bufferEvents(e){const t={buffers:new Array};this.data.push(t);const i=e();return this.data.pop(),t.buffers.forEach((e=>e())),i}},t.Relay=class{constructor(){this.listening=!1,this.inputEvent=l.None,this.inputEventListener=n.Disposable.None,this.emitter=new m({onDidAddFirstListener:()=>{this.listening=!0,this.inputEventListener=this.inputEvent(this.emitter.fire,this.emitter)},onDidRemoveLastListener:()=>{this.listening=!1,this.inputEventListener.dispose()}}),this.event=this.emitter.event}set input(e){this.inputEvent=e,this.listening&&(this.inputEventListener.dispose(),this.inputEventListener=e(this.emitter.fire,this.emitter))}dispose(){this.inputEventListener.dispose(),this.emitter.dispose()}},t.ValueWithChangeEvent=class{static const(e){return new C(e)}constructor(e){this._value=e,this._onDidChange=new m,this.onDidChange=this._onDidChange.event}get value(){return this._value}set value(e){e!==this._value&&(this._value=e,this._onDidChange.fire(void 0))}};class C{constructor(e){this.value=e,this.onDidChange=l.None}}},8841:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.createSingleCallFunction=function(e,t){const i=this;let s,r=!1;return function(){if(r)return s;if(r=!0,t)try{s=e.apply(i,arguments)}finally{t()}else s=e.apply(i,arguments);return s}}},6304:function(e,t,i){var s=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),r=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i in e)"default"!==i&&Object.prototype.hasOwnProperty.call(e,i)&&s(t,e,i);return r(t,e),t};Object.defineProperty(t,"__esModule",{value:!0}),t.StringSHA1=t.Hasher=void 0,t.hash=function(e){return a(e,0)},t.doHash=a,t.numberHash=l,t.stringHash=h,t.toHexString=_;const o=n(i(1316));function a(e,t){switch(typeof e){case"object":return null===e?l(349,t):Array.isArray(e)?(i=e,s=l(104579,s=t),i.reduce(((e,t)=>a(t,e)),s)):function(e,t){return t=l(181387,t),Object.keys(e).sort().reduce(((t,i)=>(t=h(i,t),a(e[i],t))),t)}(e,t);case"string":return h(e,t);case"boolean":return function(e,t){return l(e?433:863,t)}(e,t);case"number":return l(e,t);case"undefined":return l(937,t);default:return l(617,t)}var i,s}function l(e,t){return(t<<5)-t+e|0}function h(e,t){t=l(149417,t);for(let i=0,s=e.length;i>>s)>>>0}function u(e,t=0,i=e.byteLength,s=0){for(let r=0;re.toString(16).padStart(2,"0"))).join(""):function(e,t,i="0"){for(;e.length>>0).toString(16),t/4)}t.Hasher=class{constructor(){this._value=0}get value(){return this._value}hash(e){return this._value=a(e,this._value),this._value}},function(e){e[e.BLOCK_SIZE=64]="BLOCK_SIZE",e[e.UNICODE_REPLACEMENT=65533]="UNICODE_REPLACEMENT"}(c||(c={}));class f{static{this._bigBlock32=new DataView(new ArrayBuffer(320))}constructor(){this._h0=1732584193,this._h1=4023233417,this._h2=2562383102,this._h3=271733878,this._h4=3285377520,this._buff=new Uint8Array(c.BLOCK_SIZE+3),this._buffDV=new DataView(this._buff.buffer),this._buffLen=0,this._totalLen=0,this._leftoverHighSurrogate=0,this._finished=!1}update(e){const t=e.length;if(0===t)return;const i=this._buff;let s,r,n=this._buffLen,a=this._leftoverHighSurrogate;for(0!==a?(s=a,r=-1,a=0):(s=e.charCodeAt(0),r=0);;){let l=s;if(o.isHighSurrogate(s)){if(!(r+1>>6,e[t++]=128|(63&i)>>>0):i<65536?(e[t++]=224|(61440&i)>>>12,e[t++]=128|(4032&i)>>>6,e[t++]=128|(63&i)>>>0):(e[t++]=240|(1835008&i)>>>18,e[t++]=128|(258048&i)>>>12,e[t++]=128|(4032&i)>>>6,e[t++]=128|(63&i)>>>0),t>=c.BLOCK_SIZE&&(this._step(),t-=c.BLOCK_SIZE,this._totalLen+=c.BLOCK_SIZE,e[0]=e[c.BLOCK_SIZE+0],e[1]=e[c.BLOCK_SIZE+1],e[2]=e[c.BLOCK_SIZE+2]),t}digest(){return this._finished||(this._finished=!0,this._leftoverHighSurrogate&&(this._leftoverHighSurrogate=0,this._buffLen=this._push(this._buff,this._buffLen,c.UNICODE_REPLACEMENT)),this._totalLen+=this._buffLen,this._wrapUp()),_(this._h0)+_(this._h1)+_(this._h2)+_(this._h3)+_(this._h4)}_wrapUp(){this._buff[this._buffLen++]=128,u(this._buff,this._buffLen),this._buffLen>56&&(this._step(),u(this._buff));const e=8*this._totalLen;this._buffDV.setUint32(56,Math.floor(e/4294967296),!1),this._buffDV.setUint32(60,e%4294967296,!1),this._step()}_step(){const e=f._bigBlock32,t=this._buffDV;for(let i=0;i<64;i+=4)e.setUint32(i,t.getUint32(i,!1),!1);for(let t=64;t<320;t+=4)e.setUint32(t,d(e.getUint32(t-12,!1)^e.getUint32(t-32,!1)^e.getUint32(t-56,!1)^e.getUint32(t-64,!1),1),!1);let i,s,r,n=this._h0,o=this._h1,a=this._h2,l=this._h3,h=this._h4;for(let t=0;t<80;t++)t<20?(i=o&a|~o&l,s=1518500249):t<40?(i=o^a^l,s=1859775393):t<60?(i=o&a|o&l|a&l,s=2400959708):(i=o^a^l,s=3395469782),r=d(n,5)+i+h+s+e.getUint32(4*t,!1)&4294967295,h=l,l=a,a=d(o,30),o=n,n=r;this._h0=this._h0+n&4294967295,this._h1=this._h1+o&4294967295,this._h2=this._h2+a&4294967295,this._h3=this._h3+l&4294967295,this._h4=this._h4+h&4294967295}}t.StringSHA1=f},4218:(e,t)=>{var i;Object.defineProperty(t,"__esModule",{value:!0}),t.Iterable=void 0,function(e){function t(e){return e&&"object"==typeof e&&"function"==typeof e[Symbol.iterator]}e.is=t;const i=Object.freeze([]);function*s(e){yield e}e.empty=function(){return i},e.single=s,e.wrap=function(e){return t(e)?e:s(e)},e.from=function(e){return e||i},e.reverse=function*(e){for(let t=e.length-1;t>=0;t--)yield e[t]},e.isEmpty=function(e){return!e||!0===e[Symbol.iterator]().next().done},e.first=function(e){return e[Symbol.iterator]().next().value},e.some=function(e,t){let i=0;for(const s of e)if(t(s,i++))return!0;return!1},e.find=function(e,t){for(const i of e)if(t(i))return i},e.filter=function*(e,t){for(const i of e)t(i)&&(yield i)},e.map=function*(e,t){let i=0;for(const s of e)yield t(s,i++)},e.flatMap=function*(e,t){let i=0;for(const s of e)yield*t(s,i++)},e.concat=function*(...e){for(const t of e)yield*t},e.reduce=function(e,t,i){let s=i;for(const i of e)s=t(s,i);return s},e.slice=function*(e,t,i=e.length){for(t<0&&(t+=e.length),i<0?i+=e.length:i>e.length&&(i=e.length);tr}]},e.asyncToArray=async function(e){const t=[];for await(const i of e)t.push(i);return Promise.resolve(t)}}(i||(t.Iterable=i={}))},7883:(e,t)=>{var i,s;Object.defineProperty(t,"__esModule",{value:!0}),t.KeyMod=t.KeyCodeUtils=t.ScanCodeUtils=t.NATIVE_WINDOWS_KEY_CODE_TO_KEY_CODE=t.EVENT_KEY_CODE_MAP=t.ScanCode=t.KeyCode=void 0,t.KeyChord=function(e,t){return(e|(65535&t)<<16>>>0)>>>0},function(e){e[e.DependsOnKbLayout=-1]="DependsOnKbLayout",e[e.Unknown=0]="Unknown",e[e.Backspace=1]="Backspace",e[e.Tab=2]="Tab",e[e.Enter=3]="Enter",e[e.Shift=4]="Shift",e[e.Ctrl=5]="Ctrl",e[e.Alt=6]="Alt",e[e.PauseBreak=7]="PauseBreak",e[e.CapsLock=8]="CapsLock",e[e.Escape=9]="Escape",e[e.Space=10]="Space",e[e.PageUp=11]="PageUp",e[e.PageDown=12]="PageDown",e[e.End=13]="End",e[e.Home=14]="Home",e[e.LeftArrow=15]="LeftArrow",e[e.UpArrow=16]="UpArrow",e[e.RightArrow=17]="RightArrow",e[e.DownArrow=18]="DownArrow",e[e.Insert=19]="Insert",e[e.Delete=20]="Delete",e[e.Digit0=21]="Digit0",e[e.Digit1=22]="Digit1",e[e.Digit2=23]="Digit2",e[e.Digit3=24]="Digit3",e[e.Digit4=25]="Digit4",e[e.Digit5=26]="Digit5",e[e.Digit6=27]="Digit6",e[e.Digit7=28]="Digit7",e[e.Digit8=29]="Digit8",e[e.Digit9=30]="Digit9",e[e.KeyA=31]="KeyA",e[e.KeyB=32]="KeyB",e[e.KeyC=33]="KeyC",e[e.KeyD=34]="KeyD",e[e.KeyE=35]="KeyE",e[e.KeyF=36]="KeyF",e[e.KeyG=37]="KeyG",e[e.KeyH=38]="KeyH",e[e.KeyI=39]="KeyI",e[e.KeyJ=40]="KeyJ",e[e.KeyK=41]="KeyK",e[e.KeyL=42]="KeyL",e[e.KeyM=43]="KeyM",e[e.KeyN=44]="KeyN",e[e.KeyO=45]="KeyO",e[e.KeyP=46]="KeyP",e[e.KeyQ=47]="KeyQ",e[e.KeyR=48]="KeyR",e[e.KeyS=49]="KeyS",e[e.KeyT=50]="KeyT",e[e.KeyU=51]="KeyU",e[e.KeyV=52]="KeyV",e[e.KeyW=53]="KeyW",e[e.KeyX=54]="KeyX",e[e.KeyY=55]="KeyY",e[e.KeyZ=56]="KeyZ",e[e.Meta=57]="Meta",e[e.ContextMenu=58]="ContextMenu",e[e.F1=59]="F1",e[e.F2=60]="F2",e[e.F3=61]="F3",e[e.F4=62]="F4",e[e.F5=63]="F5",e[e.F6=64]="F6",e[e.F7=65]="F7",e[e.F8=66]="F8",e[e.F9=67]="F9",e[e.F10=68]="F10",e[e.F11=69]="F11",e[e.F12=70]="F12",e[e.F13=71]="F13",e[e.F14=72]="F14",e[e.F15=73]="F15",e[e.F16=74]="F16",e[e.F17=75]="F17",e[e.F18=76]="F18",e[e.F19=77]="F19",e[e.F20=78]="F20",e[e.F21=79]="F21",e[e.F22=80]="F22",e[e.F23=81]="F23",e[e.F24=82]="F24",e[e.NumLock=83]="NumLock",e[e.ScrollLock=84]="ScrollLock",e[e.Semicolon=85]="Semicolon",e[e.Equal=86]="Equal",e[e.Comma=87]="Comma",e[e.Minus=88]="Minus",e[e.Period=89]="Period",e[e.Slash=90]="Slash",e[e.Backquote=91]="Backquote",e[e.BracketLeft=92]="BracketLeft",e[e.Backslash=93]="Backslash",e[e.BracketRight=94]="BracketRight",e[e.Quote=95]="Quote",e[e.OEM_8=96]="OEM_8",e[e.IntlBackslash=97]="IntlBackslash",e[e.Numpad0=98]="Numpad0",e[e.Numpad1=99]="Numpad1",e[e.Numpad2=100]="Numpad2",e[e.Numpad3=101]="Numpad3",e[e.Numpad4=102]="Numpad4",e[e.Numpad5=103]="Numpad5",e[e.Numpad6=104]="Numpad6",e[e.Numpad7=105]="Numpad7",e[e.Numpad8=106]="Numpad8",e[e.Numpad9=107]="Numpad9",e[e.NumpadMultiply=108]="NumpadMultiply",e[e.NumpadAdd=109]="NumpadAdd",e[e.NUMPAD_SEPARATOR=110]="NUMPAD_SEPARATOR",e[e.NumpadSubtract=111]="NumpadSubtract",e[e.NumpadDecimal=112]="NumpadDecimal",e[e.NumpadDivide=113]="NumpadDivide",e[e.KEY_IN_COMPOSITION=114]="KEY_IN_COMPOSITION",e[e.ABNT_C1=115]="ABNT_C1",e[e.ABNT_C2=116]="ABNT_C2",e[e.AudioVolumeMute=117]="AudioVolumeMute",e[e.AudioVolumeUp=118]="AudioVolumeUp",e[e.AudioVolumeDown=119]="AudioVolumeDown",e[e.BrowserSearch=120]="BrowserSearch",e[e.BrowserHome=121]="BrowserHome",e[e.BrowserBack=122]="BrowserBack",e[e.BrowserForward=123]="BrowserForward",e[e.MediaTrackNext=124]="MediaTrackNext",e[e.MediaTrackPrevious=125]="MediaTrackPrevious",e[e.MediaStop=126]="MediaStop",e[e.MediaPlayPause=127]="MediaPlayPause",e[e.LaunchMediaPlayer=128]="LaunchMediaPlayer",e[e.LaunchMail=129]="LaunchMail",e[e.LaunchApp2=130]="LaunchApp2",e[e.Clear=131]="Clear",e[e.MAX_VALUE=132]="MAX_VALUE"}(i||(t.KeyCode=i={})),function(e){e[e.DependsOnKbLayout=-1]="DependsOnKbLayout",e[e.None=0]="None",e[e.Hyper=1]="Hyper",e[e.Super=2]="Super",e[e.Fn=3]="Fn",e[e.FnLock=4]="FnLock",e[e.Suspend=5]="Suspend",e[e.Resume=6]="Resume",e[e.Turbo=7]="Turbo",e[e.Sleep=8]="Sleep",e[e.WakeUp=9]="WakeUp",e[e.KeyA=10]="KeyA",e[e.KeyB=11]="KeyB",e[e.KeyC=12]="KeyC",e[e.KeyD=13]="KeyD",e[e.KeyE=14]="KeyE",e[e.KeyF=15]="KeyF",e[e.KeyG=16]="KeyG",e[e.KeyH=17]="KeyH",e[e.KeyI=18]="KeyI",e[e.KeyJ=19]="KeyJ",e[e.KeyK=20]="KeyK",e[e.KeyL=21]="KeyL",e[e.KeyM=22]="KeyM",e[e.KeyN=23]="KeyN",e[e.KeyO=24]="KeyO",e[e.KeyP=25]="KeyP",e[e.KeyQ=26]="KeyQ",e[e.KeyR=27]="KeyR",e[e.KeyS=28]="KeyS",e[e.KeyT=29]="KeyT",e[e.KeyU=30]="KeyU",e[e.KeyV=31]="KeyV",e[e.KeyW=32]="KeyW",e[e.KeyX=33]="KeyX",e[e.KeyY=34]="KeyY",e[e.KeyZ=35]="KeyZ",e[e.Digit1=36]="Digit1",e[e.Digit2=37]="Digit2",e[e.Digit3=38]="Digit3",e[e.Digit4=39]="Digit4",e[e.Digit5=40]="Digit5",e[e.Digit6=41]="Digit6",e[e.Digit7=42]="Digit7",e[e.Digit8=43]="Digit8",e[e.Digit9=44]="Digit9",e[e.Digit0=45]="Digit0",e[e.Enter=46]="Enter",e[e.Escape=47]="Escape",e[e.Backspace=48]="Backspace",e[e.Tab=49]="Tab",e[e.Space=50]="Space",e[e.Minus=51]="Minus",e[e.Equal=52]="Equal",e[e.BracketLeft=53]="BracketLeft",e[e.BracketRight=54]="BracketRight",e[e.Backslash=55]="Backslash",e[e.IntlHash=56]="IntlHash",e[e.Semicolon=57]="Semicolon",e[e.Quote=58]="Quote",e[e.Backquote=59]="Backquote",e[e.Comma=60]="Comma",e[e.Period=61]="Period",e[e.Slash=62]="Slash",e[e.CapsLock=63]="CapsLock",e[e.F1=64]="F1",e[e.F2=65]="F2",e[e.F3=66]="F3",e[e.F4=67]="F4",e[e.F5=68]="F5",e[e.F6=69]="F6",e[e.F7=70]="F7",e[e.F8=71]="F8",e[e.F9=72]="F9",e[e.F10=73]="F10",e[e.F11=74]="F11",e[e.F12=75]="F12",e[e.PrintScreen=76]="PrintScreen",e[e.ScrollLock=77]="ScrollLock",e[e.Pause=78]="Pause",e[e.Insert=79]="Insert",e[e.Home=80]="Home",e[e.PageUp=81]="PageUp",e[e.Delete=82]="Delete",e[e.End=83]="End",e[e.PageDown=84]="PageDown",e[e.ArrowRight=85]="ArrowRight",e[e.ArrowLeft=86]="ArrowLeft",e[e.ArrowDown=87]="ArrowDown",e[e.ArrowUp=88]="ArrowUp",e[e.NumLock=89]="NumLock",e[e.NumpadDivide=90]="NumpadDivide",e[e.NumpadMultiply=91]="NumpadMultiply",e[e.NumpadSubtract=92]="NumpadSubtract",e[e.NumpadAdd=93]="NumpadAdd",e[e.NumpadEnter=94]="NumpadEnter",e[e.Numpad1=95]="Numpad1",e[e.Numpad2=96]="Numpad2",e[e.Numpad3=97]="Numpad3",e[e.Numpad4=98]="Numpad4",e[e.Numpad5=99]="Numpad5",e[e.Numpad6=100]="Numpad6",e[e.Numpad7=101]="Numpad7",e[e.Numpad8=102]="Numpad8",e[e.Numpad9=103]="Numpad9",e[e.Numpad0=104]="Numpad0",e[e.NumpadDecimal=105]="NumpadDecimal",e[e.IntlBackslash=106]="IntlBackslash",e[e.ContextMenu=107]="ContextMenu",e[e.Power=108]="Power",e[e.NumpadEqual=109]="NumpadEqual",e[e.F13=110]="F13",e[e.F14=111]="F14",e[e.F15=112]="F15",e[e.F16=113]="F16",e[e.F17=114]="F17",e[e.F18=115]="F18",e[e.F19=116]="F19",e[e.F20=117]="F20",e[e.F21=118]="F21",e[e.F22=119]="F22",e[e.F23=120]="F23",e[e.F24=121]="F24",e[e.Open=122]="Open",e[e.Help=123]="Help",e[e.Select=124]="Select",e[e.Again=125]="Again",e[e.Undo=126]="Undo",e[e.Cut=127]="Cut",e[e.Copy=128]="Copy",e[e.Paste=129]="Paste",e[e.Find=130]="Find",e[e.AudioVolumeMute=131]="AudioVolumeMute",e[e.AudioVolumeUp=132]="AudioVolumeUp",e[e.AudioVolumeDown=133]="AudioVolumeDown",e[e.NumpadComma=134]="NumpadComma",e[e.IntlRo=135]="IntlRo",e[e.KanaMode=136]="KanaMode",e[e.IntlYen=137]="IntlYen",e[e.Convert=138]="Convert",e[e.NonConvert=139]="NonConvert",e[e.Lang1=140]="Lang1",e[e.Lang2=141]="Lang2",e[e.Lang3=142]="Lang3",e[e.Lang4=143]="Lang4",e[e.Lang5=144]="Lang5",e[e.Abort=145]="Abort",e[e.Props=146]="Props",e[e.NumpadParenLeft=147]="NumpadParenLeft",e[e.NumpadParenRight=148]="NumpadParenRight",e[e.NumpadBackspace=149]="NumpadBackspace",e[e.NumpadMemoryStore=150]="NumpadMemoryStore",e[e.NumpadMemoryRecall=151]="NumpadMemoryRecall",e[e.NumpadMemoryClear=152]="NumpadMemoryClear",e[e.NumpadMemoryAdd=153]="NumpadMemoryAdd",e[e.NumpadMemorySubtract=154]="NumpadMemorySubtract",e[e.NumpadClear=155]="NumpadClear",e[e.NumpadClearEntry=156]="NumpadClearEntry",e[e.ControlLeft=157]="ControlLeft",e[e.ShiftLeft=158]="ShiftLeft",e[e.AltLeft=159]="AltLeft",e[e.MetaLeft=160]="MetaLeft",e[e.ControlRight=161]="ControlRight",e[e.ShiftRight=162]="ShiftRight",e[e.AltRight=163]="AltRight",e[e.MetaRight=164]="MetaRight",e[e.BrightnessUp=165]="BrightnessUp",e[e.BrightnessDown=166]="BrightnessDown",e[e.MediaPlay=167]="MediaPlay",e[e.MediaRecord=168]="MediaRecord",e[e.MediaFastForward=169]="MediaFastForward",e[e.MediaRewind=170]="MediaRewind",e[e.MediaTrackNext=171]="MediaTrackNext",e[e.MediaTrackPrevious=172]="MediaTrackPrevious",e[e.MediaStop=173]="MediaStop",e[e.Eject=174]="Eject",e[e.MediaPlayPause=175]="MediaPlayPause",e[e.MediaSelect=176]="MediaSelect",e[e.LaunchMail=177]="LaunchMail",e[e.LaunchApp2=178]="LaunchApp2",e[e.LaunchApp1=179]="LaunchApp1",e[e.SelectTask=180]="SelectTask",e[e.LaunchScreenSaver=181]="LaunchScreenSaver",e[e.BrowserSearch=182]="BrowserSearch",e[e.BrowserHome=183]="BrowserHome",e[e.BrowserBack=184]="BrowserBack",e[e.BrowserForward=185]="BrowserForward",e[e.BrowserStop=186]="BrowserStop",e[e.BrowserRefresh=187]="BrowserRefresh",e[e.BrowserFavorites=188]="BrowserFavorites",e[e.ZoomToggle=189]="ZoomToggle",e[e.MailReply=190]="MailReply",e[e.MailForward=191]="MailForward",e[e.MailSend=192]="MailSend",e[e.MAX_VALUE=193]="MAX_VALUE"}(s||(t.ScanCode=s={}));class r{constructor(){this._keyCodeToStr=[],this._strToKeyCode=Object.create(null)}define(e,t){this._keyCodeToStr[e]=t,this._strToKeyCode[t.toLowerCase()]=e}keyCodeToStr(e){return this._keyCodeToStr[e]}strToKeyCode(e){return this._strToKeyCode[e.toLowerCase()]||i.Unknown}}const n=new r,o=new r,a=new r;t.EVENT_KEY_CODE_MAP=new Array(230),t.NATIVE_WINDOWS_KEY_CODE_TO_KEY_CODE={};const l=[],h=Object.create(null),c=Object.create(null);var d,u;t.ScanCodeUtils={lowerCaseToEnum:e=>c[e]||s.None,toEnum:e=>h[e]||s.None,toString:e=>l[e]||"None"},function(e){e.toString=function(e){return n.keyCodeToStr(e)},e.fromString=function(e){return n.strToKeyCode(e)},e.toUserSettingsUS=function(e){return o.keyCodeToStr(e)},e.toUserSettingsGeneral=function(e){return a.keyCodeToStr(e)},e.fromUserSettings=function(e){return o.strToKeyCode(e)||a.strToKeyCode(e)},e.toElectronAccelerator=function(e){if(e>=i.Numpad0&&e<=i.NumpadDivide)return null;switch(e){case i.UpArrow:return"Up";case i.DownArrow:return"Down";case i.LeftArrow:return"Left";case i.RightArrow:return"Right"}return n.keyCodeToStr(e)}}(d||(t.KeyCodeUtils=d={})),function(e){e[e.CtrlCmd=2048]="CtrlCmd",e[e.Shift=1024]="Shift",e[e.Alt=512]="Alt",e[e.WinCtrl=256]="WinCtrl"}(u||(t.KeyMod=u={}))},2811:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ResolvedKeybinding=t.ResolvedChord=t.Keybinding=t.ScanCodeChord=t.KeyCodeChord=void 0,t.decodeKeybinding=function(e,t){if("number"==typeof e){if(0===e)return null;const i=(65535&e)>>>0,s=(4294901760&e)>>>16;return new c(0!==s?[a(i,t),a(s,t)]:[a(i,t)])}{const i=[];for(let s=0;s{Object.defineProperty(t,"__esModule",{value:!0}),t.Lazy=void 0,t.Lazy=class{constructor(e){this.executor=e,this._didRun=!1}get hasValue(){return this._didRun}get value(){if(!this._didRun)try{this._value=this.executor()}catch(e){this._error=e}finally{this._didRun=!0}if(this._error)throw this._error;return this._value}get rawValue(){return this._value}}},7150:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DisposableMap=t.ImmortalReference=t.AsyncReferenceCollection=t.ReferenceCollection=t.SafeDisposable=t.RefCountedDisposable=t.MandatoryMutableDisposable=t.MutableDisposable=t.Disposable=t.DisposableStore=t.DisposableTracker=void 0,t.setDisposableTracker=function(e){l=e},t.trackDisposable=c,t.markAsDisposed=d,t.markAsSingleton=function(e){return l?.markAsSingleton(e),e},t.isDisposable=_,t.dispose=f,t.disposeIfDisposable=function(e){for(const t of e)_(t)&&t.dispose();return[]},t.combinedDisposable=function(...e){const t=p((()=>f(e)));return function(e,t){if(l)for(const i of e)l.setParent(i,t)}(e,t),t},t.toDisposable=p,t.disposeOnReturn=function(e){const t=new g;try{e(t)}finally{t.dispose()}};const s=i(3058),r=i(9087),n=i(2608),o=i(8841),a=i(4218);let l=null;class h{constructor(){this.livingDisposables=new Map}static{this.idx=0}getDisposableData(e){let t=this.livingDisposables.get(e);return t||(t={parent:null,source:null,isSingleton:!1,value:e,idx:h.idx++},this.livingDisposables.set(e,t)),t}trackDisposable(e){const t=this.getDisposableData(e);t.source||(t.source=(new Error).stack)}setParent(e,t){this.getDisposableData(e).parent=t}markAsDisposed(e){this.livingDisposables.delete(e)}markAsSingleton(e){this.getDisposableData(e).isSingleton=!0}getRootParent(e,t){const i=t.get(e);if(i)return i;const s=e.parent?this.getRootParent(this.getDisposableData(e.parent),t):e;return t.set(e,s),s}getTrackedDisposables(){const e=new Map;return[...this.livingDisposables.entries()].filter((([,t])=>null!==t.source&&!this.getRootParent(t,e).isSingleton)).flatMap((([e])=>e))}computeLeakingDisposables(e=10,t){let i;if(t)i=t;else{const e=new Map,t=[...this.livingDisposables.values()].filter((t=>null!==t.source&&!this.getRootParent(t,e).isSingleton));if(0===t.length)return;const s=new Set(t.map((e=>e.value)));if(i=t.filter((e=>!(e.parent&&s.has(e.parent)))),0===i.length)throw new Error("There are cyclic diposable chains!")}if(!i)return;function o(e){const t=e.source.split("\n").map((e=>e.trim().replace("at ",""))).filter((e=>""!==e));return function(e,t){for(;e.length>0&&t.some((t=>"string"==typeof t?t===e[0]:e[0].match(t)));)e.shift()}(t,["Error",/^trackDisposable \(.*\)$/,/^DisposableTracker.trackDisposable \(.*\)$/]),t.reverse()}const a=new n.SetMap;for(const e of i){const t=o(e);for(let i=0;i<=t.length;i++)a.add(t.slice(0,i).join("\n"),e)}i.sort((0,s.compareBy)((e=>e.idx),s.numberComparator));let l="",h=0;for(const t of i.slice(0,e)){h++;const e=o(t),s=[];for(let t=0;to(e)[t])),(e=>e));delete h[e[t]];for(const[e,t]of Object.entries(h))s.unshift(` - stacktraces of ${t.length} other leaks continue with ${e}`);s.unshift(n)}l+=`\n\n\n==================== Leaking disposable ${h}/${i.length}: ${t.value.constructor.name} ====================\n${s.join("\n")}\n============================================================\n\n`}return i.length>e&&(l+=`\n\n\n... and ${i.length-e} more leaking disposables\n\n`),{leaks:i,details:l}}}function c(e){return l?.trackDisposable(e),e}function d(e){l?.markAsDisposed(e)}function u(e,t){l?.setParent(e,t)}function _(e){return"object"==typeof e&&null!==e&&"function"==typeof e.dispose&&0===e.dispose.length}function f(e){if(a.Iterable.is(e)){const t=[];for(const i of e)if(i)try{i.dispose()}catch(e){t.push(e)}if(1===t.length)throw t[0];if(t.length>1)throw new AggregateError(t,"Encountered errors while disposing of store");return Array.isArray(e)?[]:e}if(e)return e.dispose(),e}function p(e){const t=c({dispose:(0,o.createSingleCallFunction)((()=>{d(t),e()}))});return t}t.DisposableTracker=h;class g{static{this.DISABLE_DISPOSED_WARNING=!1}constructor(){this._toDispose=new Set,this._isDisposed=!1,c(this)}dispose(){this._isDisposed||(d(this),this._isDisposed=!0,this.clear())}get isDisposed(){return this._isDisposed}clear(){if(0!==this._toDispose.size)try{f(this._toDispose)}finally{this._toDispose.clear()}}add(e){if(!e)return e;if(e===this)throw new Error("Cannot register a disposable on itself!");return u(e,this),this._isDisposed?g.DISABLE_DISPOSED_WARNING||console.warn(new Error("Trying to add a disposable to a DisposableStore that has already been disposed of. The added object will be leaked!").stack):this._toDispose.add(e),e}delete(e){if(e){if(e===this)throw new Error("Cannot dispose a disposable on itself!");this._toDispose.delete(e),e.dispose()}}deleteAndLeak(e){e&&this._toDispose.has(e)&&(this._toDispose.delete(e),u(e,null))}}t.DisposableStore=g;class m{static{this.None=Object.freeze({dispose(){}})}constructor(){this._store=new g,c(this),u(this._store,this)}dispose(){d(this),this._store.dispose()}_register(e){if(e===this)throw new Error("Cannot register a disposable on itself!");return this._store.add(e)}}t.Disposable=m;class v{constructor(){this._isDisposed=!1,c(this)}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),e&&u(e,this),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,d(this),this._value?.dispose(),this._value=void 0}clearAndLeak(){const e=this._value;return this._value=void 0,e&&u(e,null),e}}t.MutableDisposable=v,t.MandatoryMutableDisposable=class{constructor(e){this._disposable=new v,this._isDisposed=!1,this._disposable.value=e}get value(){return this._disposable.value}set value(e){this._isDisposed||e===this._disposable.value||(this._disposable.value=e)}dispose(){this._isDisposed=!0,this._disposable.dispose()}},t.RefCountedDisposable=class{constructor(e){this._disposable=e,this._counter=1}acquire(){return this._counter++,this}release(){return 0==--this._counter&&this._disposable.dispose(),this}},t.SafeDisposable=class{constructor(){this.dispose=()=>{},this.unset=()=>{},this.isset=()=>!1,c(this)}set(e){let t=e;return this.unset=()=>t=void 0,this.isset=()=>void 0!==t,this.dispose=()=>{t&&(t(),t=void 0,d(this))},this}},t.ReferenceCollection=class{constructor(){this.references=new Map}acquire(e,...t){let i=this.references.get(e);i||(i={counter:0,object:this.createReferencedObject(e,...t)},this.references.set(e,i));const{object:s}=i,r=(0,o.createSingleCallFunction)((()=>{0==--i.counter&&(this.destroyReferencedObject(e,i.object),this.references.delete(e))}));return i.counter++,{object:s,dispose:r}}},t.AsyncReferenceCollection=class{constructor(e){this.referenceCollection=e}async acquire(e,...t){const i=this.referenceCollection.acquire(e,...t);try{return{object:await i.object,dispose:()=>i.dispose()}}catch(e){throw i.dispose(),e}}},t.ImmortalReference=class{constructor(e){this.object=e}dispose(){}};class S{constructor(){this._store=new Map,this._isDisposed=!1,c(this)}dispose(){d(this),this._isDisposed=!0,this.clearAndDisposeAll()}clearAndDisposeAll(){if(this._store.size)try{f(this._store.values())}finally{this._store.clear()}}has(e){return this._store.has(e)}get size(){return this._store.size}get(e){return this._store.get(e)}set(e,t,i=!1){this._isDisposed&&console.warn(new Error("Trying to add a disposable to a DisposableMap that has already been disposed of. The added object will be leaked!").stack),i||this._store.get(e)?.dispose(),this._store.set(e,t)}deleteAndDispose(e){this._store.get(e)?.dispose(),this._store.delete(e)}deleteAndLeak(e){const t=this._store.get(e);return this._store.delete(e),t}keys(){return this._store.keys()}values(){return this._store.values()}[Symbol.iterator](){return this._store[Symbol.iterator]()}}t.DisposableMap=S},6317:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.LinkedList=void 0;class i{static{this.Undefined=new i(void 0)}constructor(e){this.element=e,this.next=i.Undefined,this.prev=i.Undefined}}class s{constructor(){this._first=i.Undefined,this._last=i.Undefined,this._size=0}get size(){return this._size}isEmpty(){return this._first===i.Undefined}clear(){let e=this._first;for(;e!==i.Undefined;){const t=e.next;e.prev=i.Undefined,e.next=i.Undefined,e=t}this._first=i.Undefined,this._last=i.Undefined,this._size=0}unshift(e){return this._insert(e,!1)}push(e){return this._insert(e,!0)}_insert(e,t){const s=new i(e);if(this._first===i.Undefined)this._first=s,this._last=s;else if(t){const e=this._last;this._last=s,s.prev=e,e.next=s}else{const e=this._first;this._first=s,s.next=e,e.prev=s}this._size+=1;let r=!1;return()=>{r||(r=!0,this._remove(s))}}shift(){if(this._first!==i.Undefined){const e=this._first.element;return this._remove(this._first),e}}pop(){if(this._last!==i.Undefined){const e=this._last.element;return this._remove(this._last),e}}_remove(e){if(e.prev!==i.Undefined&&e.next!==i.Undefined){const t=e.prev;t.next=e.next,e.next.prev=t}else e.prev===i.Undefined&&e.next===i.Undefined?(this._first=i.Undefined,this._last=i.Undefined):e.next===i.Undefined?(this._last=this._last.prev,this._last.next=i.Undefined):e.prev===i.Undefined&&(this._first=this._first.next,this._first.prev=i.Undefined);this._size-=1}*[Symbol.iterator](){let e=this._first;for(;e!==i.Undefined;)yield e.element,e=e.next}}t.LinkedList=s},2608:(e,t)=>{var i;Object.defineProperty(t,"__esModule",{value:!0}),t.SetMap=t.BidirectionalMap=t.CounterSet=t.Touch=void 0,t.getOrSet=function(e,t,i){let s=e.get(t);return void 0===s&&(s=i,e.set(t,s)),s},t.mapToString=function(e){const t=[];return e.forEach(((e,i)=>{t.push(`${i} => ${e}`)})),`Map(${e.size}) {${t.join(", ")}}`},t.setToString=function(e){const t=[];return e.forEach((e=>{t.push(e)})),`Set(${e.size}) {${t.join(", ")}}`},t.mapsStrictEqualIgnoreOrder=function(e,t){if(e===t)return!0;if(e.size!==t.size)return!1;for(const[i,s]of e)if(!t.has(i)||t.get(i)!==s)return!1;for(const[i]of t)if(!e.has(i))return!1;return!0},function(e){e[e.None=0]="None",e[e.AsOld=1]="AsOld",e[e.AsNew=2]="AsNew"}(i||(t.Touch=i={})),t.CounterSet=class{constructor(){this.map=new Map}add(e){return this.map.set(e,(this.map.get(e)||0)+1),this}delete(e){let t=this.map.get(e)||0;return 0!==t&&(t--,0===t?this.map.delete(e):this.map.set(e,t),!0)}has(e){return this.map.has(e)}},t.BidirectionalMap=class{constructor(e){if(this._m1=new Map,this._m2=new Map,e)for(const[t,i]of e)this.set(t,i)}clear(){this._m1.clear(),this._m2.clear()}set(e,t){this._m1.set(e,t),this._m2.set(t,e)}get(e){return this._m1.get(e)}getKey(e){return this._m2.get(e)}delete(e){const t=this._m1.get(e);return void 0!==t&&(this._m1.delete(e),this._m2.delete(t),!0)}forEach(e,t){this._m1.forEach(((i,s)=>{e.call(t,i,s,this)}))}keys(){return this._m1.keys()}values(){return this._m1.values()}},t.SetMap=class{constructor(){this.map=new Map}add(e,t){let i=this.map.get(e);i||(i=new Set,this.map.set(e,i)),i.add(t)}delete(e,t){const i=this.map.get(e);i&&(i.delete(t),0===i.size&&this.map.delete(e))}forEach(e,t){const i=this.map.get(e);i&&i.forEach(t)}get(e){return this.map.get(e)||new Set}}},7704:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SlidingWindowAverage=t.MovingAverage=t.Counter=void 0,t.clamp=function(e,t,i){return Math.min(Math.max(e,t),i)},t.rot=function(e,t){return(t+e%t)%t},t.isPointWithinTriangle=function(e,t,i,s,r,n,o,a){const l=o-i,h=a-s,c=r-i,d=n-s,u=e-i,_=t-s,f=l*l+h*h,p=l*c+h*d,g=l*u+h*_,m=c*c+d*d,v=c*u+d*_,S=1/(f*m-p*p),b=(m*g-p*v)*S,C=(f*v-p*g)*S;return b>=0&&C>=0&&b+C<1},t.Counter=class{constructor(){this._next=0}getNext(){return this._next++}},t.MovingAverage=class{constructor(){this._n=1,this._val=0}update(e){return this._val=this._val+(e-this._val)/this._n,this._n+=1,this._val}get value(){return this._val}},t.SlidingWindowAverage=class{constructor(e){this._n=0,this._val=0,this._values=[],this._index=0,this._sum=0,this._values=new Array(e),this._values.fill(0,0,e)}update(e){const t=this._values[this._index];return this._values[this._index]=e,this._index=(this._index+1)%this._values.length,this._sum-=t,this._sum+=e,this._n{Object.defineProperty(t,"__esModule",{value:!0}),t.isAndroid=t.isEdge=t.isSafari=t.isFirefox=t.isChrome=t.OS=t.OperatingSystem=t.setTimeout0=t.setTimeout0IsFaster=t.translationsConfigFile=t.platformLocale=t.locale=t.Language=t.language=t.userAgent=t.platform=t.isCI=t.isMobile=t.isIOS=t.webWorkerOrigin=t.isWebWorker=t.isWeb=t.isElectron=t.isNative=t.isLinuxSnap=t.isLinux=t.isMacintosh=t.isWindows=t.Platform=t.LANGUAGE_DEFAULT=void 0,t.PlatformToString=function(e){switch(e){case C.Web:return"Web";case C.Mac:return"Mac";case C.Linux:return"Linux";case C.Windows:return"Windows"}},t.isLittleEndian=function(){if(!L){L=!0;const e=new Uint8Array(2);e[0]=1,e[1]=2;const t=new Uint16Array(e.buffer);D=513===t[0]}return D},t.isBigSurOrNewer=function(e){return parseFloat(e)>=20},t.LANGUAGE_DEFAULT="en";let i,s,r,n=!1,o=!1,a=!1,l=!1,h=!1,c=!1,d=!1,u=!1,_=!1,f=!1,p=t.LANGUAGE_DEFAULT,g=t.LANGUAGE_DEFAULT;const m=globalThis;let v;void 0!==m.vscode&&void 0!==m.vscode.process?v=m.vscode.process:"undefined"!=typeof process&&"string"==typeof process?.versions?.node&&(v=process);const S="string"==typeof v?.versions?.electron,b=S&&"renderer"===v?.type;if("object"==typeof v){n="win32"===v.platform,o="darwin"===v.platform,a="linux"===v.platform,l=a&&!!v.env.SNAP&&!!v.env.SNAP_REVISION,d=S,_=!!v.env.CI||!!v.env.BUILD_ARTIFACTSTAGINGDIRECTORY,i=t.LANGUAGE_DEFAULT,p=t.LANGUAGE_DEFAULT;const e=v.env.VSCODE_NLS_CONFIG;if(e)try{const r=JSON.parse(e);i=r.userLocale,g=r.osLocale,p=r.resolvedLanguage||t.LANGUAGE_DEFAULT,s=r.languagePack?.translationsConfigFile}catch(e){}h=!0}else"object"!=typeof navigator||b?console.error("Unable to resolve platform."):(r=navigator.userAgent,n=r.indexOf("Windows")>=0,o=r.indexOf("Macintosh")>=0,u=(r.indexOf("Macintosh")>=0||r.indexOf("iPad")>=0||r.indexOf("iPhone")>=0)&&!!navigator.maxTouchPoints&&navigator.maxTouchPoints>0,a=r.indexOf("Linux")>=0,f=r?.indexOf("Mobi")>=0,c=!0,p=globalThis._VSCODE_NLS_LANGUAGE||t.LANGUAGE_DEFAULT,i=navigator.language.toLowerCase(),g=i);var C;!function(e){e[e.Web=0]="Web",e[e.Mac=1]="Mac",e[e.Linux=2]="Linux",e[e.Windows=3]="Windows"}(C||(t.Platform=C={}));let y=C.Web;var w,E;o?y=C.Mac:n?y=C.Windows:a&&(y=C.Linux),t.isWindows=n,t.isMacintosh=o,t.isLinux=a,t.isLinuxSnap=l,t.isNative=h,t.isElectron=d,t.isWeb=c,t.isWebWorker=c&&"function"==typeof m.importScripts,t.webWorkerOrigin=t.isWebWorker?m.origin:void 0,t.isIOS=u,t.isMobile=f,t.isCI=_,t.platform=y,t.userAgent=r,t.language=p,function(e){e.value=function(){return t.language},e.isDefaultVariant=function(){return 2===t.language.length?"en"===t.language:t.language.length>=3&&"e"===t.language[0]&&"n"===t.language[1]&&"-"===t.language[2]},e.isDefault=function(){return"en"===t.language}}(w||(t.Language=w={})),t.locale=i,t.platformLocale=g,t.translationsConfigFile=s,t.setTimeout0IsFaster="function"==typeof m.postMessage&&!m.importScripts,t.setTimeout0=(()=>{if(t.setTimeout0IsFaster){const e=[];m.addEventListener("message",(t=>{if(t.data&&t.data.vscodeScheduleAsyncWork)for(let i=0,s=e.length;i{const s=++t;e.push({id:s,callback:i}),m.postMessage({vscodeScheduleAsyncWork:s},"*")}}return e=>setTimeout(e)})(),function(e){e[e.Windows=1]="Windows",e[e.Macintosh=2]="Macintosh",e[e.Linux=3]="Linux"}(E||(t.OperatingSystem=E={})),t.OS=o||u?E.Macintosh:n?E.Windows:E.Linux;let D=!0,L=!1;t.isChrome=!!(t.userAgent&&t.userAgent.indexOf("Chrome")>=0),t.isFirefox=!!(t.userAgent&&t.userAgent.indexOf("Firefox")>=0),t.isSafari=!!(!t.isChrome&&t.userAgent&&t.userAgent.indexOf("Safari")>=0),t.isEdge=!!(t.userAgent&&t.userAgent.indexOf("Edg/")>=0),t.isAndroid=!!(t.userAgent&&t.userAgent.indexOf("Android")>=0)},9881:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SmoothScrollingOperation=t.SmoothScrollingUpdate=t.Scrollable=t.ScrollState=t.ScrollbarVisibility=void 0;const s=i(802),r=i(7150);var n;!function(e){e[e.Auto=1]="Auto",e[e.Hidden=2]="Hidden",e[e.Visible=3]="Visible"}(n||(t.ScrollbarVisibility=n={}));class o{constructor(e,t,i,s,r,n,o){this._forceIntegerValues=e,this._scrollStateBrand=void 0,this._forceIntegerValues&&(t|=0,i|=0,s|=0,r|=0,n|=0,o|=0),this.rawScrollLeft=s,this.rawScrollTop=o,t<0&&(t=0),s+t>i&&(s=i-t),s<0&&(s=0),r<0&&(r=0),o+r>n&&(o=n-r),o<0&&(o=0),this.width=t,this.scrollWidth=i,this.scrollLeft=s,this.height=r,this.scrollHeight=n,this.scrollTop=o}equals(e){return this.rawScrollLeft===e.rawScrollLeft&&this.rawScrollTop===e.rawScrollTop&&this.width===e.width&&this.scrollWidth===e.scrollWidth&&this.scrollLeft===e.scrollLeft&&this.height===e.height&&this.scrollHeight===e.scrollHeight&&this.scrollTop===e.scrollTop}withScrollDimensions(e,t){return new o(this._forceIntegerValues,void 0!==e.width?e.width:this.width,void 0!==e.scrollWidth?e.scrollWidth:this.scrollWidth,t?this.rawScrollLeft:this.scrollLeft,void 0!==e.height?e.height:this.height,void 0!==e.scrollHeight?e.scrollHeight:this.scrollHeight,t?this.rawScrollTop:this.scrollTop)}withScrollPosition(e){return new o(this._forceIntegerValues,this.width,this.scrollWidth,void 0!==e.scrollLeft?e.scrollLeft:this.rawScrollLeft,this.height,this.scrollHeight,void 0!==e.scrollTop?e.scrollTop:this.rawScrollTop)}createScrollEvent(e,t){const i=this.width!==e.width,s=this.scrollWidth!==e.scrollWidth,r=this.scrollLeft!==e.scrollLeft,n=this.height!==e.height,o=this.scrollHeight!==e.scrollHeight,a=this.scrollTop!==e.scrollTop;return{inSmoothScrolling:t,oldWidth:e.width,oldScrollWidth:e.scrollWidth,oldScrollLeft:e.scrollLeft,width:this.width,scrollWidth:this.scrollWidth,scrollLeft:this.scrollLeft,oldHeight:e.height,oldScrollHeight:e.scrollHeight,oldScrollTop:e.scrollTop,height:this.height,scrollHeight:this.scrollHeight,scrollTop:this.scrollTop,widthChanged:i,scrollWidthChanged:s,scrollLeftChanged:r,heightChanged:n,scrollHeightChanged:o,scrollTopChanged:a}}}t.ScrollState=o;class a extends r.Disposable{constructor(e){super(),this._scrollableBrand=void 0,this._onScroll=this._register(new s.Emitter),this.onScroll=this._onScroll.event,this._smoothScrollDuration=e.smoothScrollDuration,this._scheduleAtNextAnimationFrame=e.scheduleAtNextAnimationFrame,this._state=new o(e.forceIntegerValues,0,0,0,0,0,0),this._smoothScrolling=null}dispose(){this._smoothScrolling&&(this._smoothScrolling.dispose(),this._smoothScrolling=null),super.dispose()}setSmoothScrollDuration(e){this._smoothScrollDuration=e}validateScrollPosition(e){return this._state.withScrollPosition(e)}getScrollDimensions(){return this._state}setScrollDimensions(e,t){const i=this._state.withScrollDimensions(e,t);this._setState(i,Boolean(this._smoothScrolling)),this._smoothScrolling?.acceptScrollDimensions(this._state)}getFutureScrollPosition(){return this._smoothScrolling?this._smoothScrolling.to:this._state}getCurrentScrollPosition(){return this._state}setScrollPositionNow(e){const t=this._state.withScrollPosition(e);this._smoothScrolling&&(this._smoothScrolling.dispose(),this._smoothScrolling=null),this._setState(t,!1)}setScrollPositionSmooth(e,t){if(0===this._smoothScrollDuration)return this.setScrollPositionNow(e);if(this._smoothScrolling){e={scrollLeft:void 0===e.scrollLeft?this._smoothScrolling.to.scrollLeft:e.scrollLeft,scrollTop:void 0===e.scrollTop?this._smoothScrolling.to.scrollTop:e.scrollTop};const i=this._state.withScrollPosition(e);if(this._smoothScrolling.to.scrollLeft===i.scrollLeft&&this._smoothScrolling.to.scrollTop===i.scrollTop)return;let s;s=t?new c(this._smoothScrolling.from,i,this._smoothScrolling.startTime,this._smoothScrolling.duration):this._smoothScrolling.combine(this._state,i,this._smoothScrollDuration),this._smoothScrolling.dispose(),this._smoothScrolling=s}else{const t=this._state.withScrollPosition(e);this._smoothScrolling=c.start(this._state,t,this._smoothScrollDuration)}this._smoothScrolling.animationFrameDisposable=this._scheduleAtNextAnimationFrame((()=>{this._smoothScrolling&&(this._smoothScrolling.animationFrameDisposable=null,this._performSmoothScrolling())}))}hasPendingScrollAnimation(){return Boolean(this._smoothScrolling)}_performSmoothScrolling(){if(!this._smoothScrolling)return;const e=this._smoothScrolling.tick(),t=this._state.withScrollPosition(e);return this._setState(t,!0),this._smoothScrolling?e.isDone?(this._smoothScrolling.dispose(),void(this._smoothScrolling=null)):void(this._smoothScrolling.animationFrameDisposable=this._scheduleAtNextAnimationFrame((()=>{this._smoothScrolling&&(this._smoothScrolling.animationFrameDisposable=null,this._performSmoothScrolling())}))):void 0}_setState(e,t){const i=this._state;i.equals(e)||(this._state=e,this._onScroll.fire(this._state.createScrollEvent(i,t)))}}t.Scrollable=a;class l{constructor(e,t,i){this.scrollLeft=e,this.scrollTop=t,this.isDone=i}}function h(e,t){const i=t-e;return function(t){return e+i*(1-(s=1-t,Math.pow(s,3)));var s}}t.SmoothScrollingUpdate=l;class c{constructor(e,t,i,s){this.from=e,this.to=t,this.duration=s,this.startTime=i,this.animationFrameDisposable=null,this._initAnimations()}_initAnimations(){this.scrollLeft=this._initAnimation(this.from.scrollLeft,this.to.scrollLeft,this.to.width),this.scrollTop=this._initAnimation(this.from.scrollTop,this.to.scrollTop,this.to.height)}_initAnimation(e,t,i){if(Math.abs(e-t)>2.5*i){let o,a;return e{Object.defineProperty(t,"__esModule",{value:!0}),t.StopWatch=void 0;const i=globalThis.performance&&"function"==typeof globalThis.performance.now;class s{static create(e){return new s(e)}constructor(e){this._now=i&&!1===e?Date.now:globalThis.performance.now.bind(globalThis.performance),this._startTime=this._now(),this._stopTime=-1}stop(){this._stopTime=this._now()}reset(){this._startTime=this._now(),this._stopTime=-1}elapsed(){return-1!==this._stopTime?this._stopTime-this._startTime:this._now()-this._startTime}}t.StopWatch=s},1316:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.noBreakWhitespace=t.CodePointIterator=void 0,t.isFalsyOrWhitespace=function(e){return!e||"string"!=typeof e||0===e.trim().length},t.format=function(e,...t){return 0===t.length?e:e.replace(n,(function(e,i){const s=parseInt(i,10);return isNaN(s)||s<0||s>=t.length?e:t[s]}))},t.format2=function(e,t){return 0===Object.keys(t).length?e:e.replace(o,((e,i)=>t[i]??e))},t.htmlAttributeEncodeValue=function(e){return e.replace(/[<>"'&]/g,(e=>{switch(e){case"<":return"<";case">":return">";case'"':return""";case"'":return"'";case"&":return"&"}return e}))},t.escape=function(e){return e.replace(/[<>&]/g,(function(e){switch(e){case"<":return"<";case">":return">";case"&":return"&";default:return e}}))},t.escapeRegExpCharacters=a,t.count=function(e,t){let i=0,s=e.indexOf(t);for(;-1!==s;)i++,s=e.indexOf(t,s+t.length);return i},t.truncate=function(e,t,i="…"){return e.length<=t?e:`${e.substr(0,t)}${i}`},t.truncateMiddle=function(e,t,i="…"){if(e.length<=t)return e;const s=Math.ceil(t/2)-i.length/2,r=Math.floor(t/2)-i.length/2;return`${e.substr(0,s)}${i}${e.substr(e.length-r)}`},t.trim=function(e,t=" "){return h(l(e,t),t)},t.ltrim=l,t.rtrim=h,t.convertSimple2RegExpPattern=function(e){return e.replace(/[\-\\\{\}\+\?\|\^\$\.\,\[\]\(\)\#\s]/g,"\\$&").replace(/[\*]/g,".*")},t.stripWildcards=function(e){return e.replace(/\*/g,"")},t.createRegExp=function(e,t,i={}){if(!e)throw new Error("Cannot create regex from empty string");t||(e=a(e)),i.wholeWord&&(/\B/.test(e.charAt(0))||(e="\\b"+e),/\B/.test(e.charAt(e.length-1))||(e+="\\b"));let s="";return i.global&&(s+="g"),i.matchCase||(s+="i"),i.multiline&&(s+="m"),i.unicode&&(s+="u"),new RegExp(e,s)},t.regExpLeadsToEndlessLoop=function(e){return"^"!==e.source&&"^$"!==e.source&&"$"!==e.source&&"^\\s*$"!==e.source&&!(!e.exec("")||0!==e.lastIndex)},t.splitLines=function(e){return e.split(/\r\n|\r|\n/)},t.splitLinesIncludeSeparators=function(e){const t=[],i=e.split(/(\r\n|\r|\n)/);for(let e=0;e=0;i--){const t=e.charCodeAt(i);if(t!==s.CharCode.Space&&t!==s.CharCode.Tab)return i}return-1},t.replaceAsync=function(e,t,i){const s=[];let r=0;for(const n of e.matchAll(t)){if(s.push(e.slice(r,n.index)),void 0===n.index)throw new Error("match.index should be defined");r=n.index+n[0].length,s.push(i(n[0],...n.slice(1),n.index,e,n.groups))}return s.push(e.slice(r)),Promise.all(s).then((e=>e.join("")))},t.compare=function(e,t){return et?1:0},t.compareSubstring=c,t.compareIgnoreCase=function(e,t){return d(e,t,0,e.length,0,t.length)},t.compareSubstringIgnoreCase=d,t.isAsciiDigit=function(e){return e>=s.CharCode.Digit0&&e<=s.CharCode.Digit9},t.isLowerAsciiLetter=u,t.isUpperAsciiLetter=function(e){return e>=s.CharCode.A&&e<=s.CharCode.Z},t.equalsIgnoreCase=function(e,t){return e.length===t.length&&0===d(e,t)},t.startsWithIgnoreCase=function(e,t){const i=t.length;return!(t.length>e.length)&&0===d(e,t,0,i)},t.commonPrefixLength=function(e,t){const i=Math.min(e.length,t.length);let s;for(s=0;sn)return 1}const o=s-i,a=n-r;return oa?1:0}function d(e,t,i=0,s=e.length,r=0,n=t.length){for(;i=128||a>=128)return c(e.toLowerCase(),t.toLowerCase(),i,s,r,n);u(o)&&(o-=32),u(a)&&(a-=32);const l=o-a;if(0!==l)return l}const o=s-i,a=n-r;return oa?1:0}function u(e){return e>=s.CharCode.a&&e<=s.CharCode.z}function _(e){return 55296<=e&&e<=56319}function f(e){return 56320<=e&&e<=57343}function p(e,t){return t-56320+(e-55296<<10)+65536}function g(e,t,i){const s=e.charCodeAt(i);if(_(s)&&i+11){const s=e.charCodeAt(t-2);if(_(s))return p(s,i)}return i}(this._str,this._offset);return this._offset-=e>=r.Constants.UNICODE_SUPPLEMENTARY_PLANE_BEGIN?2:1,e}nextCodePoint(){const e=g(this._str,this._len,this._offset);return this._offset+=e>=r.Constants.UNICODE_SUPPLEMENTARY_PLANE_BEGIN?2:1,e}eol(){return this._offset>=this._len}},t.noBreakWhitespace=" "},5015:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.MicrotaskDelay=void 0,t.MicrotaskDelay=Symbol("MicrotaskDelay")},8960:(e,t)=>{var i;Object.defineProperty(t,"__esModule",{value:!0}),t.Constants=void 0,t.toUint8=function(e){return e<0?0:e>i.MAX_UINT_8?i.MAX_UINT_8:0|e},t.toUint32=function(e){return e<0?0:e>i.MAX_UINT_32?i.MAX_UINT_32:0|e},function(e){e[e.MAX_SAFE_SMALL_INTEGER=1073741824]="MAX_SAFE_SMALL_INTEGER",e[e.MIN_SAFE_SMALL_INTEGER=-1073741824]="MIN_SAFE_SMALL_INTEGER",e[e.MAX_UINT_8=255]="MAX_UINT_8",e[e.MAX_UINT_16=65535]="MAX_UINT_16",e[e.MAX_UINT_32=4294967295]="MAX_UINT_32",e[e.UNICODE_SUPPLEMENTARY_PLANE_BEGIN=65536]="UNICODE_SUPPLEMENTARY_PLANE_BEGIN"}(i||(t.Constants=i={}))}},t={};function i(s){var r=t[s];if(void 0!==r)return r.exports;var n=t[s]={exports:{}};return e[s].call(n.exports,n,n.exports,i),n.exports}var s={};return(()=>{var e=s;Object.defineProperty(e,"__esModule",{value:!0}),e.Terminal=void 0;const t=i(7721),r=i(1718),n=i(7150),o=i(3027),a=i(5101),l=i(6097),h=i(4335),c=["cols","rows"];let d=0;class u extends n.Disposable{constructor(e){super(),this._core=this._register(new r.CoreBrowserTerminal(e)),this._addonManager=this._register(new o.AddonManager),this._publicOptions={...this._core.options};const t=e=>this._core.options[e],i=(e,t)=>{this._checkReadonlyOptions(e),this._core.options[e]=t};for(const e in this._core.options){const s={get:t.bind(this,e),set:i.bind(this,e)};Object.defineProperty(this._publicOptions,e,s)}}_checkReadonlyOptions(e){if(c.includes(e))throw new Error(`Option "${e}" can only be set in the constructor`)}_checkProposedApi(){if(!this._core.optionsService.rawOptions.allowProposedApi)throw new Error("You must set the allowProposedApi option to true to use proposed API")}get onBell(){return this._core.onBell}get onBinary(){return this._core.onBinary}get onCursorMove(){return this._core.onCursorMove}get onData(){return this._core.onData}get onKey(){return this._core.onKey}get onLineFeed(){return this._core.onLineFeed}get onRender(){return this._core.onRender}get onResize(){return this._core.onResize}get onScroll(){return this._core.onScroll}get onSelectionChange(){return this._core.onSelectionChange}get onTitleChange(){return this._core.onTitleChange}get onWriteParsed(){return this._core.onWriteParsed}get element(){return this._core.element}get parser(){return this._parser||(this._parser=new l.ParserApi(this._core)),this._parser}get unicode(){return this._checkProposedApi(),new h.UnicodeApi(this._core)}get textarea(){return this._core.textarea}get rows(){return this._core.rows}get cols(){return this._core.cols}get buffer(){return this._buffer||(this._buffer=this._register(new a.BufferNamespaceApi(this._core))),this._buffer}get markers(){return this._checkProposedApi(),this._core.markers}get modes(){const e=this._core.coreService.decPrivateModes;let t="none";switch(this._core.coreMouseService.activeProtocol){case"X10":t="x10";break;case"VT200":t="vt200";break;case"DRAG":t="drag";break;case"ANY":t="any"}return{applicationCursorKeysMode:e.applicationCursorKeys,applicationKeypadMode:e.applicationKeypad,bracketedPasteMode:e.bracketedPasteMode,insertMode:this._core.coreService.modes.insertMode,mouseTrackingMode:t,originMode:e.origin,reverseWraparoundMode:e.reverseWraparound,sendFocusMode:e.sendFocus,synchronizedOutputMode:e.synchronizedOutput,wraparoundMode:e.wraparound}}get options(){return this._publicOptions}set options(e){for(const t in e)this._publicOptions[t]=e[t]}blur(){this._core.blur()}focus(){this._core.focus()}input(e,t=!0){this._core.input(e,t)}resize(e,t){this._verifyIntegers(e,t),this._core.resize(e,t)}open(e){this._core.open(e)}attachCustomKeyEventHandler(e){this._core.attachCustomKeyEventHandler(e)}attachCustomWheelEventHandler(e){this._core.attachCustomWheelEventHandler(e)}registerLinkProvider(e){return this._core.registerLinkProvider(e)}registerCharacterJoiner(e){return this._checkProposedApi(),this._core.registerCharacterJoiner(e)}deregisterCharacterJoiner(e){this._checkProposedApi(),this._core.deregisterCharacterJoiner(e)}registerMarker(e=0){return this._verifyIntegers(e),this._core.registerMarker(e)}registerDecoration(e){return this._checkProposedApi(),this._verifyPositiveIntegers(e.x??0,e.width??0,e.height??0),this._core.registerDecoration(e)}hasSelection(){return this._core.hasSelection()}select(e,t,i){this._verifyIntegers(e,t,i),this._core.select(e,t,i)}getSelection(){return this._core.getSelection()}getSelectionPosition(){return this._core.getSelectionPosition()}clearSelection(){this._core.clearSelection()}selectAll(){this._core.selectAll()}selectLines(e,t){this._verifyIntegers(e,t),this._core.selectLines(e,t)}dispose(){super.dispose()}scrollLines(e){this._verifyIntegers(e),this._core.scrollLines(e)}scrollPages(e){this._verifyIntegers(e),this._core.scrollPages(e)}scrollToTop(){this._core.scrollToTop()}scrollToBottom(){this._core.scrollToBottom()}scrollToLine(e){this._verifyIntegers(e),this._core.scrollToLine(e)}clear(){this._core.clear()}write(e,t){this._core.write(e,t)}writeln(e,t){this._core.write(e),this._core.write("\r\n",t)}paste(e){this._core.paste(e)}refresh(e,t){this._verifyIntegers(e,t),this._core.refresh(e,t)}reset(){this._core.reset()}clearTextureAtlas(){this._core.clearTextureAtlas()}loadAddon(e){this._addonManager.loadAddon(this,e)}static get strings(){return{get promptLabel(){return t.promptLabel.get()},set promptLabel(e){t.promptLabel.set(e)},get tooMuchOutput(){return t.tooMuchOutput.get()},set tooMuchOutput(e){t.tooMuchOutput.set(e)}}}_verifyIntegers(...e){for(d of e)if(d===1/0||isNaN(d)||d%1!=0)throw new Error("This API only accepts integers")}_verifyPositiveIntegers(...e){for(d of e)if(d&&(d===1/0||isNaN(d)||d%1!=0||d<0))throw new Error("This API only accepts positive integers")}}e.Terminal=u})(),s})())); //# sourceMappingURL=xterm.js.map \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 12a8301b..8d7817a6 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1419,6 +1419,7 @@

File Preview

window.WEBSSH_SSH_INPUT_LIMITS = Object.freeze({{ ssh_input_limits | tojson }}); + diff --git a/tests/conftest.py b/tests/conftest.py index 4fdca80c..9a9d4b5b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -38,6 +38,7 @@ def _fast_test_gensalt(rounds=12, prefix=b'2b'): 'DATA_DIR': str(_SESSION_TEST_ROOT / 'data'), 'TRANSFER_TEMP_DIR': str(_SESSION_TEST_ROOT / 'transfers'), 'BACKUP_TEMP_DIR': str(_SESSION_TEST_ROOT / 'backups'), + 'BACKUP_RECOVERY_DURABLE': 'true', 'RATELIMIT_STORAGE_URL': 'memory://', 'LDAP_ENABLED': 'false', 'LDAP_AUTO_PROVISION': 'false', diff --git a/tests/e2e/mfa.spec.js b/tests/e2e/mfa.spec.js index e5c137b6..31bdc62a 100644 --- a/tests/e2e/mfa.spec.js +++ b/tests/e2e/mfa.spec.js @@ -56,6 +56,10 @@ test('enrolls optional TOTP and completes password plus MFA login', async ({ 'type', 'password', ); + await expect(page.locator('#securityConfirmationPassword')).toHaveAttribute( + 'autocomplete', + 'current-password', + ); await page.locator('#securityConfirmationPassword').fill('browser-password'); await page.locator('#securityConfirmationSubmit').click(); diff --git a/tests/e2e/smb-file-workspace.spec.js b/tests/e2e/smb-file-workspace.spec.js index d5a7cf56..fe9ddad3 100644 --- a/tests/e2e/smb-file-workspace.spec.js +++ b/tests/e2e/smb-file-workspace.spec.js @@ -362,6 +362,7 @@ test('SMB editor remembers recoverable-swap consent for the current connection', error: 'This SMB account cannot replace the file atomically.', code: 'SMB_RECOVERABLE_REPLACE_REQUIRED', revision: 'a'.repeat(64), + save_challenge: 'c'.repeat(43), }); preview.saveEdit(); @@ -381,12 +382,14 @@ test('SMB editor remembers recoverable-swap consent for the current connection', expect(state.emitted[1]).toMatchObject({ expected_revision: 'a'.repeat(64), replace_strategy: 'recoverable_swap', + save_challenge: 'c'.repeat(43), }); expect(state.emitted[1]).not.toHaveProperty('allow_non_atomic'); expect(state.emitted[2]).toMatchObject({ expected_revision: 'a'.repeat(64), replace_strategy: 'recoverable_swap', }); + expect(state.emitted[2]).not.toHaveProperty('save_challenge'); expect(state.status).toBe('Saving...'); await assertNoExternalRequests(page); }); diff --git a/tests/integration/smb/entrypoint.sh b/tests/integration/smb/entrypoint.sh index 84cdce17..163274ea 100644 --- a/tests/integration/smb/entrypoint.sh +++ b/tests/integration/smb/entrypoint.sh @@ -28,4 +28,12 @@ chown root:root /srv/samba/docs/atomic-denied /srv/samba/docs/atomic-denied/repl chmod 1733 /srv/samba/docs/atomic-denied chmod 666 /srv/samba/docs/atomic-denied/replace-denied.txt +# Known descendants remain accessible even when the parent directory itself +# may be traversed but not listed. This exercises the opaque-path fallback. +mkdir -p /srv/samba/docs/known-only +printf '%s\n' 'Known-path integration fixture' > /srv/samba/docs/known-only/known.txt +chown root:root /srv/samba/docs/known-only /srv/samba/docs/known-only/known.txt +chmod 711 /srv/samba/docs/known-only +chmod 644 /srv/samba/docs/known-only/known.txt + exec smbd --foreground --no-process-group --debug-stdout --configfile=/etc/samba/smb.conf diff --git a/tests/integration/test_paramiko5_socketio.py b/tests/integration/test_paramiko5_socketio.py index 245c636f..874de5b8 100644 --- a/tests/integration/test_paramiko5_socketio.py +++ b/tests/integration/test_paramiko5_socketio.py @@ -108,6 +108,7 @@ def terminal_marker_command(marker, prefix=''): def create_authenticated_socket(app, username): from app import socketio from app.auth import register_user + from app.socket_protocol import SOCKET_WIRE_REVISION with app.app_context(): user, error = register_user(username, 'socket-password-123') @@ -124,6 +125,7 @@ def create_authenticated_socket(app, username): socket_client = socketio.test_client( app, flask_test_client=http_client, + auth={'wire_revision': SOCKET_WIRE_REVISION}, ) assert socket_client.is_connected() wait_for_event(socket_client, 'connected') diff --git a/tests/integration/test_smb_integration.py b/tests/integration/test_smb_integration.py index 120de20f..b35fbe7d 100644 --- a/tests/integration/test_smb_integration.py +++ b/tests/integration/test_smb_integration.py @@ -78,6 +78,17 @@ def run_checks(): if link is not None: assert link['is_dir'] is False + hidden_listing, error = backend.list_directory(source, '/known-only') + assert hidden_listing is None and error == 'Permission denied', ( + hidden_listing, + error, + ) + with backend.open_reader(source, '/known-only/known.txt') as lease: + known_payload = b''.join( + iter(lambda: lease.reader.read(65536), b'') + ) + assert known_payload == b'Known-path integration fixture\n' + payload = ('SMB 3.1.1 encrypted round trip — ' * 4096).encode('utf-8') with backend.open_atomic_writer( source, @@ -92,6 +103,40 @@ def run_checks(): downloaded = b''.join(iter(lambda: lease.reader.read(65536), b'')) assert hashlib.sha256(downloaded).digest() == hashlib.sha256(payload).digest() + actual_source = pool.get_source( + descriptor.source_id, + 'integration-user', + ) + original_invoke = actual_source.session.invoke + response_lost = False + + def commit_then_timeout(name, *args, **kwargs): + nonlocal response_lost + result = original_invoke(name, *args, **kwargs) + if ( + name == 'rename_open_handle_verified' + and args[1].endswith(r'\ack-reconciled.bin') + and not response_lost + ): + response_lost = True + raise SMBProtocolError('TIMEOUT') + return result + + actual_source.session.invoke = commit_then_timeout + try: + with backend.open_atomic_writer( + source, + '/ack-reconciled.bin', + replace=False, + cancel_event=Event(), + ) as remote_file: + remote_file.write(b'committed despite lost response') + finally: + actual_source.session.invoke = original_invoke + assert response_lost is True + with backend.open_reader(source, '/ack-reconciled.bin') as lease: + assert lease.reader.read() == b'committed despite lost response' + unicode_stat, error = backend.get_file_stat(source, '/Überblick.txt') assert error is None and unicode_stat['size'] > 0 @@ -111,11 +156,86 @@ def run_checks(): newline='lf', expected_revision=original_revision, ) + assert edit_outcome.code == 'SMB_RECOVERABLE_REPLACE_REQUIRED' + edit_outcome = backend.write_file_text( + source, + '/atomic-edit.txt', + 'after', + encoding='utf-8', + newline='lf', + expected_revision=original_revision, + replace_strategy='recoverable_swap', + ) assert edit_outcome.success is True, edit_outcome assert edit_outcome.revision == hashlib.sha256(b'after').hexdigest() with backend.open_reader(source, '/atomic-edit.txt') as lease: assert lease.reader.read() == b'after' + for scenario, target_rename, expected_bytes in ( + ('recovery-rename-interrupt', 1, b'before'), + ('install-rename-interrupt', 2, b'before'), + ('backup-delete-interrupt', None, b'after'), + ): + editor_path = f'/{scenario}.txt' + with backend.open_atomic_writer( + source, + editor_path, + replace=False, + cancel_event=Event(), + ) as remote_file: + remote_file.write(b'before') + original_invoke = actual_source.session.invoke + rename_count = 0 + interrupted = False + + def interrupt_after_committed_phase(name, *args, **kwargs): + nonlocal interrupted, rename_count + result = original_invoke(name, *args, **kwargs) + if interrupted: + return result + if name == 'rename_open_handle_verified': + rename_count += 1 + if rename_count == target_rename: + interrupted = True + raise KeyboardInterrupt + elif ( + target_rename is None + and name == 'delete_open_handle_verified' + ): + interrupted = True + raise KeyboardInterrupt + return result + + actual_source.session.invoke = interrupt_after_committed_phase + try: + try: + backend.write_file_text( + source, + editor_path, + 'after', + encoding='utf-8', + newline='lf', + expected_revision=hashlib.sha256(b'before').hexdigest(), + replace_strategy='recoverable_swap', + ) + except KeyboardInterrupt: + pass + else: + raise AssertionError( + f'{scenario} did not propagate its control-flow abort' + ) + finally: + actual_source.session.invoke = original_invoke + assert interrupted is True + with backend.open_reader(source, editor_path) as lease: + assert lease.reader.read() == expected_bytes + listing, error = backend.list_directory(source, '/') + assert error is None, error + assert not any( + scenario in item['name'] and '.webssh-' in item['name'] + for item in listing + ) + protected_path = '/atomic-denied/replace-denied.txt' with backend.open_reader(source, protected_path) as lease: protected_original = lease.reader.read() diff --git a/tests/js/browser-error-reporting.test.js b/tests/js/browser-error-reporting.test.js index 637e093e..9f56f75d 100644 --- a/tests/js/browser-error-reporting.test.js +++ b/tests/js/browser-error-reporting.test.js @@ -43,6 +43,10 @@ function loadBrowserErrorHandler(errors) { fs.readFileSync('static/js/socket-reconnect-policy.js', 'utf8'), context ); + vm.runInContext( + fs.readFileSync('static/js/socket-protocol.js', 'utf8'), + context + ); vm.runInContext(fs.readFileSync('static/js/app.js', 'utf8'), context); return handlers.get('error'); } diff --git a/tests/js/profile-manager.test.js b/tests/js/profile-manager.test.js index 999acd9e..d6614a32 100644 --- a/tests/js/profile-manager.test.js +++ b/tests/js/profile-manager.test.js @@ -41,6 +41,31 @@ test('partial key mutation replies preserve transient usability', () => { assert.equal(manager.keys[0].usable, true); }); +test('profile upsert replaces fields cleared by the authoritative server record', () => { + const manager = loadProfileManager(); + manager.renderProfileSelect = () => {}; + manager.renderManagementList = () => {}; + manager.refreshEmptyPanes = () => {}; + manager.profiles = [{ + id: 'profile-1', + name: 'Production API', + group: 'Production', + favorite: true, + }]; + + manager.upsertProfile({ + id: 'profile-1', + name: 'Production API', + }); + + assert.deepEqual(manager.profiles, [{ + id: 'profile-1', + name: 'Production API', + }]); + assert.equal('group' in manager.profiles[0], false); + assert.equal('favorite' in manager.profiles[0], false); +}); + test('failed key replacement preserves private-key draft for retry', () => { const manager = loadProfileManager(); manager.keys = [{ @@ -133,6 +158,35 @@ test('favorite acknowledgement replaces cleared organization fields', () => { assert.equal(manager.profiles[0].tailscale_authorized, true); }); +test('favorite acknowledgement adopts fresh tailscale authorization', () => { + const manager = loadProfileManager(); + manager.profiles = [{ + id: 'profile-1', + name: 'Tailnet API', + favorite: false, + tailscale_authorized: true, + }]; + manager.renderManagementList = () => {}; + manager.refreshEmptyPanes = () => {}; + manager.renderProfileSelect = () => {}; + manager.organizationPending = new Set(); + manager.t = (_key, fallback) => fallback; + manager.toggleFavorite('profile-1', acknowledgement => { + acknowledgement({ + success: true, + profile: { + id: 'profile-1', + name: 'Tailnet API', + favorite: true, + tailscale_authorized: false, + }, + }); + }); + + assert.equal(manager.profiles[0].favorite, true); + assert.equal(manager.profiles[0].tailscale_authorized, false); +}); + test('collapsed group state toggles for the session but search keeps matches visible', () => { const manager = loadProfileManager(); manager.collapsedGroups = new Set(); @@ -252,7 +306,9 @@ test('moving a profile applies only the authoritative successful response', () = }); acknowledge({ success: true, - profiles: [{id: 'profile-1', name: 'API', group: 'Homelab', sort_order: 0}], + organization: [{ + id: 'profile-1', group: 'Homelab', sort_order: 0, + }], }); }); @@ -280,7 +336,7 @@ test('move failure keeps the original state and adopts authoritative stale state acknowledge({ success: false, error: 'Profile group changed; retry move', - profiles: [{id: 'profile-1', name: 'API', group: 'Current'}], + organization: [{id: 'profile-1', group: 'Current', sort_order: 0}], }); }), true); assert.equal(manager.profiles[0].group, 'Current'); @@ -307,14 +363,13 @@ test('group removal acknowledgement opens confirmation and retries explicitly', requires_confirmation: true, profile_name: 'DB', source_group: 'Databases', - profiles: manager.profiles, }); return; } acknowledge({ success: true, requires_confirmation: false, - profiles: [{id: 'profile-1', name: 'DB', group: 'Apps', sort_order: 0}], + organization: [{id: 'profile-1', group: 'Apps', sort_order: 0}], }); }; const move = { diff --git a/tests/js/security-ui.test.js b/tests/js/security-ui.test.js index e181ca12..e7704b9a 100644 --- a/tests/js/security-ui.test.js +++ b/tests/js/security-ui.test.js @@ -172,6 +172,37 @@ test('passkey step-up serializes the assertion and returns an exact grant header }); }); +test('initial-factor bootstrap submits only the action-bound enrollment code', async () => { + const calls = []; + const client = createAccountStepUpClient({ + api: async (path, options) => { + calls.push([path, options]); + if (path.endsWith('/intents')) { + return { + intent: 'intent-bootstrap', + preferred_method: 'bootstrap', + methods: ['bootstrap'] + }; + } + return { grant: 'grant-bootstrap' }; + }, + requestSecret: async method => { + assert.equal(method, 'bootstrap'); + return 'operator-issued-code'; + } + }); + + assert.equal( + await client.authorize('passkey.enroll', 17), + 'grant-bootstrap' + ); + assert.equal(calls[1][0], '/api/account/step-up/bootstrap'); + assert.deepEqual(calls[1][1].body, { + intent: 'intent-bootstrap', + code: 'operator-issued-code' + }); +}); + test('account step-up lets the user choose an available strong method', async () => { const calls = []; const client = createAccountStepUpClient({ diff --git a/tests/js/session-manager-close.test.js b/tests/js/session-manager-close.test.js index c242a668..f53006ad 100644 --- a/tests/js/session-manager-close.test.js +++ b/tests/js/session-manager-close.test.js @@ -59,7 +59,11 @@ function createElement(tagName = 'div') { return element; } -function loadSessionManager(confirmSessionClose, disconnectSessionAction) { +function loadSessionManager( + confirmSessionClose, + disconnectSessionAction, + connectionHistoryScope = '', +) { const source = fs.readFileSync( path.join(__dirname, '..', '..', 'static', 'js', 'session-manager.js'), 'utf8', @@ -69,9 +73,19 @@ function loadSessionManager(confirmSessionClose, disconnectSessionAction) { body.dataset = { confirmSessionClose: String(confirmSessionClose), disconnectSessionAction: disconnectSessionAction || '', + connectionHistoryScope, + }; + const stored = new Map(); + const localStorage = { + get length() { return stored.size; }, + key(index) { return Array.from(stored.keys())[index] ?? null; }, + getItem(key) { return stored.has(key) ? stored.get(key) : null; }, + setItem(key, value) { stored.set(String(key), String(value)); }, + removeItem(key) { stored.delete(String(key)); }, }; const context = { console, + localStorage, document: { body, createElement, @@ -106,6 +120,7 @@ function loadSessionManager(confirmSessionClose, disconnectSessionAction) { return { manager: context.__SessionManager, context, + localStorage, createElement, registerElement(id, element) { element.id = id; @@ -115,6 +130,71 @@ function loadSessionManager(confirmSessionClose, disconnectSessionAction) { }; } +test('account-scoped aliases stay isolated and survive account switching', () => { + const { + manager, context, localStorage, + } = loadSessionManager(false, 'retry', 'account-b-scope'); + localStorage.setItem('sessionDisplayNames', JSON.stringify({leak: 'legacy'})); + localStorage.setItem( + 'sessionDisplayNames:account-a-scope', + JSON.stringify({target: 'Customer A production'}), + ); + localStorage.setItem('sessionDisplayNames:activeScope', 'account-a-scope'); + + manager.initializeDisplayNameStorage(); + + assert.equal(localStorage.getItem('sessionDisplayNames'), null); + assert.equal( + localStorage.getItem('sessionDisplayNames:account-a-scope'), + JSON.stringify({target: 'Customer A production'}), + ); + + manager.writeDisplayNames({target: 'Customer B production'}); + assert.deepEqual(JSON.parse(JSON.stringify(manager.readDisplayNames())), { + target: 'Customer B production', + }); + + context.document.body.dataset.connectionHistoryScope = 'account-a-scope'; + manager.initializeDisplayNameStorage(); + assert.equal( + localStorage.getItem('sessionDisplayNames:account-b-scope'), + JSON.stringify({target: 'Customer B production'}), + ); + assert.deepEqual(JSON.parse(JSON.stringify(manager.readDisplayNames())), { + target: 'Customer A production', + }); +}); + +test('explicit logout retains namespaced convenience data', () => { + const {manager, localStorage} = loadSessionManager( + false, 'retry', 'account-a-scope' + ); + localStorage.setItem( + 'sessionDisplayNames:account-a-scope', + JSON.stringify({target: 'Sensitive alias'}), + ); + localStorage.setItem( + 'recentConnections:account-a-scope', + JSON.stringify([{host: 'target'}]), + ); + localStorage.setItem('sessionDisplayNames:activeScope', 'account-a-scope'); + + manager.clearScopedBrowserStorage(); + + assert.equal( + localStorage.getItem('sessionDisplayNames:account-a-scope'), + JSON.stringify({target: 'Sensitive alias'}), + ); + assert.equal( + localStorage.getItem('recentConnections:account-a-scope'), + JSON.stringify([{host: 'target'}]), + ); + assert.equal( + localStorage.getItem('sessionDisplayNames:activeScope'), + null, + ); +}); + function prepareSession(manager) { manager.sessions = { sessionA: { username: 'alice', host: 'example.test' }, diff --git a/tests/js/sftp-transfer-queue.test.js b/tests/js/sftp-transfer-queue.test.js index 5a6dc18d..b9e02f37 100644 --- a/tests/js/sftp-transfer-queue.test.js +++ b/tests/js/sftp-transfer-queue.test.js @@ -69,6 +69,73 @@ function filePane(manager, sourceId, overrides = {}) { }; } +function focusableControl(textContent = '') { + const attributes = new Map(); + return { + disabled: false, + textContent, + focusOptions: null, + setAttribute(name, value) { attributes.set(name, String(value)); }, + getAttribute(name) { return attributes.get(name) ?? null; }, + focus(options) { + this.focusOptions = options; + global.document.activeElement = this; + }, + }; +} + +function paginationList(loadMore = null, initialHtml = 'existing rows') { + const attributes = new Map(); + let html = initialHtml; + return { + currentLoadMore: loadMore, + renderCount: 0, + scrollTop: 0, + focusOptions: null, + set innerHTML(value) { + html = value; + this.renderCount += 1; + this.scrollTop = 0; + this.currentLoadMore = ( + String(value).includes('data-move-picker-more') + || String(value).includes('data-load-more') + ) + ? focusableControl('Load more') + : null; + }, + get innerHTML() { return html; }, + setAttribute(name, value) { attributes.set(name, String(value)); }, + getAttribute(name) { return attributes.get(name) ?? null; }, + querySelector(selector) { + if (selector === '[data-load-more]' + || selector === '[data-move-picker-more]') { + return this.currentLoadMore; + } + return null; + }, + querySelectorAll() { return []; }, + focus(options) { + this.focusOptions = options; + global.document.activeElement = this; + }, + }; +} + +function movePickerElement(list) { + const controls = { + '[data-move-picker-path]': { textContent: '' }, + '[data-move-picker-up]': { disabled: false }, + '[data-move-picker-home]': { disabled: false }, + '[data-move-picker-refresh]': { disabled: false }, + '[data-move-picker-status]': { dataset: {}, textContent: '' }, + '[data-move-picker-list]': list, + '[data-move-picker-confirm]': { disabled: false }, + }; + return { + querySelector(selector) { return controls[selector] || null; }, + }; +} + test('standalone workspace starts in one pane with independent empty states', () => { const manager = Object.create(SFTPFileManager.prototype); @@ -787,54 +854,1026 @@ test('closing the final SMB tab uses the generic owned-source disconnect', () => openSourceLauncher() {}, updateSessionLists() {}, }); - manager.closeSourceTab('left', tab.id); + manager.closeSourceTab('left', tab.id); + + assert.deepEqual(emitted, [[ + 'file_source_disconnect', { source_id: 'smb-quick:owned' }, + ]]); + assert.deepEqual(manager.smbSources, []); +}); + +test('a correlated listing updates an inactive source tab without replacing the visible tab', () => { + const listeners = {}; + const manager = Object.create(SFTPFileManager.prototype); + manager.initializeWorkspaceState(); + const inactiveState = filePane(manager, 'sftp-session:session-a', { + loading: true, + pendingDirectoryRequestId: 'left:directory:1', pendingDirectoryPath: '/srv/a', + }); + const activeState = filePane(manager, 'sftp-session:session-b', { + path: '/srv/b', + files: [{ name: 'visible.txt' }], + }); + manager.workspace.openTab( + 'left', fileSource('sftp-session:session-a', { label: 'A' }), + inactiveState, + ); + const activeTab = manager.workspace.openTab( + 'left', fileSource('sftp-session:session-b', { label: 'B' }), + activeState, + ); + manager.syncPaneFromWorkspace('left'); + Object.assign(manager, { + socket: { on(event, callback) { listeners[event] = callback; } }, + isOpen: true, + displayMode: 'modal', + updatePathInput() { assert.fail('inactive tab changed the visible path'); }, + renderPane() { assert.fail('inactive tab re-rendered the visible pane'); }, + }); + manager.setupSocketListeners(); + + listeners.directory_listing({ + source_id: 'sftp-session:session-a', request_id: 'left:directory:1', path: '/srv/a', + files: [{ name: 'late.txt' }], + }); + + assert.equal(manager.workspace.getActiveTab('left'), activeTab); + assert.equal(manager.panes.left, activeState); + assert.equal(manager.panes.left.files[0].name, 'visible.txt'); + assert.equal(inactiveState.loading, false); + assert.equal(inactiveState.files[0].name, 'late.txt'); +}); + +test('opaque directory cursors append pages and are echoed unchanged', () => { + const listeners = {}; + const emitted = []; + const token = `v1.abcdefghijklmnop.1.${'a'.repeat(32)}`; + const manager = Object.create(SFTPFileManager.prototype); + const state = filePane(manager, 'sftp-session:session-a', { + path: '/srv', + loading: true, + pendingDirectoryRequestId: 'left:directory:1', + pendingDirectoryPath: '/srv', + pendingDirectoryCursor: 0, + }); + Object.assign(manager, { + requestSequence: 1, + socket: { + on(event, callback) { listeners[event] = callback; }, + emit(event, payload) { emitted.push({ event, payload }); }, + }, + isOpen: true, + panes: { left: state, right: manager.createEmptyPaneState() }, + updatePathInput() {}, + renderPane() {}, + setLoadingTimeout() {}, + }); + manager.setupSocketListeners(); + + listeners.directory_listing({ + source_id: 'sftp-session:session-a', + request_id: 'left:directory:1', + path: '/srv', + cursor: 0, + files: [{ name: 'one' }], + next_cursor: token, + }); + assert.equal(state.nextDirectoryCursor, token); + assert.equal(manager.requestNextDirectoryPage('left'), true); + assert.equal(emitted[0].event, 'list_directory'); + assert.equal(emitted[0].payload.cursor, token); + + listeners.directory_listing({ + source_id: 'sftp-session:session-a', + request_id: emitted[0].payload.request_id, + path: '/srv', + cursor: token, + files: [{ name: 'two' }], + next_cursor: null, + }); + + assert.deepEqual(state.files, [{ name: 'one' }, { name: 'two' }]); + assert.equal(state.nextDirectoryCursor, null); + assert.equal(state.loadingMore, false); +}); + +test('starting a new pane listing retires its previous exact cursor first', () => { + const emitted = []; + const token = `v1.abcdefghijklmnop.1.${'a'.repeat(32)}`; + const manager = Object.create(SFTPFileManager.prototype); + const state = filePane(manager, 'sftp-session:session-a', { + path: '/old', + nextDirectoryCursor: token, + }); + Object.assign(manager, { + requestSequence: 0, + socket: { emit(event, payload) { emitted.push({ event, payload }); } }, + panes: { left: state, right: manager.createEmptyPaneState() }, + }); + + manager.requestDirectoryForState('left', state, '/new'); + + assert.deepEqual(emitted.map(item => item.event), [ + 'cancel_directory_listing', + 'list_directory', + ]); + assert.equal(emitted[0].payload.source_id, 'sftp-session:session-a'); + assert.equal(emitted[0].payload.cursor, token); + assert.equal(emitted[1].payload.remote_path, '/new'); + assert.equal(emitted[1].payload.cursor, 0); +}); + +test('starting a new pane page zero cancels its exact prior request first', () => { + const emitted = []; + const manager = Object.create(SFTPFileManager.prototype); + const state = filePane(manager, 'sftp-session:session-a', { + path: '/old', + loading: true, + pendingDirectoryRequestId: 'left:directory:old', + pendingDirectoryPath: '/old', + pendingDirectoryCursor: 0, + }); + const otherState = filePane(manager, 'sftp-session:session-a', { + path: '/other', + loading: true, + pendingDirectoryRequestId: 'right:directory:untouched', + pendingDirectoryPath: '/other', + pendingDirectoryCursor: 0, + }); + Object.assign(manager, { + requestSequence: 0, + socket: { emit(event, payload) { emitted.push({ event, payload }); } }, + panes: { left: state, right: otherState }, + }); + + manager.requestDirectoryForState('left', state, '/new'); + + assert.deepEqual(emitted.map(item => item.event), [ + 'cancel_directory_listing', + 'list_directory', + ]); + assert.equal( + emitted[0].payload.listing_request_id, + 'left:directory:old', + ); + assert.notEqual( + emitted[0].payload.request_id, + emitted[0].payload.listing_request_id, + ); + assert.equal(emitted[1].payload.remote_path, '/new'); + assert.equal(emitted[1].payload.cursor, 0); + assert.equal( + otherState.pendingDirectoryRequestId, + 'right:directory:untouched', + ); +}); + +test('an unclaimed directory response immediately retires its returned cursor', () => { + const listeners = {}; + const emitted = []; + const token = `v1.abcdefghijklmnop.1.${'b'.repeat(32)}`; + const manager = Object.create(SFTPFileManager.prototype); + Object.assign(manager, { + requestSequence: 0, + socket: { + on(event, callback) { listeners[event] = callback; }, + emit(event, payload) { emitted.push({ event, payload }); }, + }, + isOpen: true, + displayMode: 'embedded', + panes: { + left: filePane(manager, 'sftp-session:session-a'), + right: manager.createEmptyPaneState(), + }, + }); + manager.setupSocketListeners(); + + listeners.directory_listing({ + source_id: 'sftp-session:session-a', + request_id: 'abandoned:directory:1', + path: '/abandoned', + cursor: 0, + files: [{ name: 'first-page' }], + next_cursor: token, + }); + + assert.equal(emitted.length, 1); + assert.equal(emitted[0].event, 'cancel_directory_listing'); + assert.equal(emitted[0].payload.cursor, token); +}); + +test('closing a source tab retires its paginated directory snapshot', () => { + const emitted = []; + const token = `v1.abcdefghijklmnop.1.${'c'.repeat(32)}`; + const manager = Object.create(SFTPFileManager.prototype); + manager.initializeWorkspaceState(); + const state = filePane(manager, 'sftp-session:session-a', { + nextDirectoryCursor: token, + }); + const tab = manager.workspace.openTab( + 'left', + fileSource('sftp-session:session-a'), + state, + ); + manager.syncPaneFromWorkspace('left'); + Object.assign(manager, { + displayMode: 'modal', + isOpen: true, + socket: { emit(event, payload) { emitted.push({ event, payload }); } }, + updatePathInput() {}, + updatePaneBadge() {}, + renderPane() {}, + renderWorkspaceChrome() {}, + }); + + manager.closeSourceTab('left', tab.id); + + assert.equal(emitted.length, 1); + assert.equal(emitted[0].event, 'cancel_directory_listing'); + assert.equal(emitted[0].payload.cursor, token); +}); + +test('closing a source tab cancels only its pending page-zero request', () => { + const emitted = []; + const manager = Object.create(SFTPFileManager.prototype); + manager.initializeWorkspaceState(); + const closingState = filePane(manager, 'sftp-session:session-a', { + loading: true, + pendingDirectoryRequestId: 'left:directory:pending', + pendingDirectoryPath: '/srv/a', + pendingDirectoryCursor: 0, + }); + const remainingState = filePane(manager, 'sftp-session:session-a', { + loading: true, + pendingDirectoryRequestId: 'left:directory:remaining', + pendingDirectoryPath: '/srv/b', + pendingDirectoryCursor: 0, + }); + const closingTab = manager.workspace.openTab( + 'left', + closingState.source, + closingState, + ); + manager.workspace.openTab( + 'left', + remainingState.source, + remainingState, + ); + manager.workspace.activateTab('left', closingTab.id); + manager.syncPaneFromWorkspace('left'); + Object.assign(manager, { + displayMode: 'modal', + isOpen: true, + socket: { emit(event, payload) { emitted.push({ event, payload }); } }, + updatePathInput() {}, + updatePaneBadge() {}, + renderPane() {}, + renderWorkspaceChrome() {}, + }); + + manager.closeSourceTab('left', closingTab.id); + + assert.equal(emitted.length, 1); + assert.equal(emitted[0].event, 'cancel_directory_listing'); + assert.equal( + emitted[0].payload.listing_request_id, + 'left:directory:pending', + ); + assert.equal( + remainingState.pendingDirectoryRequestId, + 'left:directory:remaining', + ); +}); + +test('closing the manager retires snapshots and refreshes them on next use', () => { + const emitted = []; + const token = `v1.abcdefghijklmnop.1.${'d'.repeat(32)}`; + const manager = Object.create(SFTPFileManager.prototype); + manager.initializeWorkspaceState(); + const state = filePane(manager, 'sftp-session:session-a', { + path: '/srv', + files: [{ name: 'partial' }], + nextDirectoryCursor: token, + }); + manager.workspace.openTab('left', state.source, state); + manager.syncPaneFromWorkspace('left'); + Object.assign(manager, { + requestSequence: 0, + displayMode: 'modal', + isOpen: true, + socket: { emit(event, payload) { emitted.push({ event, payload }); } }, + modal: { classList: classList(), setAttribute() {} }, + closeSourceLauncher() {}, + closeContextMenu() {}, + setLoadingTimeout() {}, + }); + + manager.close({ restorePrimaryWorkspace: false }); + + assert.equal(state.directoryNeedsRefresh, true); + assert.equal(state.nextDirectoryCursor, null); + assert.equal(emitted[0].event, 'cancel_directory_listing'); + assert.equal(emitted[0].payload.cursor, token); + + assert.equal(manager.resumeDirectoryListingIfNeeded('left', state), true); + assert.equal(state.directoryNeedsRefresh, false); + assert.equal(emitted.at(-1).event, 'list_directory'); + assert.equal(emitted.at(-1).payload.remote_path, '/srv'); + assert.equal(emitted.at(-1).payload.cursor, 0); +}); + +test('closing the manager cancels a pending page-zero request before reopen', () => { + const emitted = []; + const manager = Object.create(SFTPFileManager.prototype); + manager.initializeWorkspaceState(); + const state = filePane(manager, 'sftp-session:session-a', { + path: '/srv', + loading: true, + pendingDirectoryRequestId: 'left:directory:pending', + pendingDirectoryPath: '/srv', + pendingDirectoryCursor: 0, + }); + manager.workspace.openTab('left', state.source, state); + manager.syncPaneFromWorkspace('left'); + Object.assign(manager, { + requestSequence: 0, + displayMode: 'modal', + isOpen: true, + socket: { emit(event, payload) { emitted.push({ event, payload }); } }, + modal: { classList: classList(), setAttribute() {} }, + closeSourceLauncher() {}, + closeContextMenu() {}, + setLoadingTimeout() {}, + }); + + manager.close({ restorePrimaryWorkspace: false }); + + assert.equal(state.directoryNeedsRefresh, true); + assert.equal(emitted.length, 1); + assert.equal(emitted[0].event, 'cancel_directory_listing'); + assert.equal( + emitted[0].payload.listing_request_id, + 'left:directory:pending', + ); + assert.equal(manager.resumeDirectoryListingIfNeeded('left', state), true); + assert.equal(emitted[1].event, 'list_directory'); + assert.equal(emitted[1].payload.cursor, 0); +}); + +test('pane continuation keeps rows in place and restores load-more focus and scroll', () => { + const previousDocument = global.document; + const listeners = {}; + const emitted = []; + const token = `v1.abcdefghijklmnop.1.${'a'.repeat(32)}`; + const nextToken = `v1.abcdefghijklmnop.2.${'b'.repeat(32)}`; + const oldLoadMore = focusableControl('Load more'); + const list = paginationList(oldLoadMore); + list.scrollTop = 318; + global.document = { + activeElement: oldLoadMore, + getElementById(id) { return id === 'fmLeftList' ? list : null; }, + }; + + try { + const manager = Object.create(SFTPFileManager.prototype); + const state = filePane(manager, 'sftp-session:session-a', { + path: '/srv', + files: [{ name: 'one' }], + nextDirectoryCursor: token, + }); + let renders = 0; + Object.assign(manager, { + requestSequence: 0, + socket: { + on(event, callback) { listeners[event] = callback; }, + emit(event, payload) { emitted.push({ event, payload }); }, + }, + isOpen: true, + displayMode: 'embedded', + panes: { left: state, right: manager.createEmptyPaneState() }, + updatePathInput() {}, + renderPane() { + renders += 1; + list.innerHTML = ''; + }, + setLoadingTimeout() {}, + t(_key, fallback) { return fallback; }, + }); + manager.setupSocketListeners(); + + assert.equal(manager.requestNextDirectoryPage('left'), true); + assert.equal(renders, 0); + assert.equal(list.innerHTML, 'existing rows'); + assert.deepEqual(state.files, [{ name: 'one' }]); + assert.equal(oldLoadMore.disabled, true); + assert.equal(oldLoadMore.getAttribute('aria-busy'), 'true'); + assert.equal(oldLoadMore.textContent, 'Loading...'); + assert.equal(list.getAttribute('aria-busy'), 'true'); + + listeners.directory_listing({ + source_id: 'sftp-session:session-a', + request_id: emitted[0].payload.request_id, + path: '/srv', + cursor: token, + files: [{ name: 'two' }], + next_cursor: nextToken, + }); + + assert.equal(renders, 1); + assert.deepEqual(state.files, [{ name: 'one' }, { name: 'two' }]); + assert.equal(list.scrollTop, 318); + const replacementLoadMore = list.currentLoadMore; + assert.notEqual(replacementLoadMore, oldLoadMore); + assert.deepEqual(replacementLoadMore.focusOptions, { preventScroll: true }); + assert.equal(global.document.activeElement, replacementLoadMore); + } finally { + global.document = previousDocument; + } +}); + +test('pane final continuation page focuses a deterministic list target', () => { + const previousDocument = global.document; + const listeners = {}; + const emitted = []; + const token = `v1.abcdefghijklmnop.1.${'a'.repeat(32)}`; + const oldLoadMore = focusableControl('Load more'); + const list = paginationList(oldLoadMore); + list.scrollTop = 144; + global.document = { + activeElement: oldLoadMore, + getElementById(id) { return id === 'fmLeftList' ? list : null; }, + }; + + try { + const manager = Object.create(SFTPFileManager.prototype); + const state = filePane(manager, 'sftp-session:session-a', { + path: '/srv', + files: [{ name: 'one' }], + nextDirectoryCursor: token, + }); + Object.assign(manager, { + requestSequence: 0, + socket: { + on(event, callback) { listeners[event] = callback; }, + emit(event, payload) { emitted.push({ event, payload }); }, + }, + isOpen: true, + displayMode: 'embedded', + panes: { left: state, right: manager.createEmptyPaneState() }, + updatePathInput() {}, + renderPane() { + list.innerHTML = '
all rows loaded
'; + }, + setLoadingTimeout() {}, + t(_key, fallback) { return fallback; }, + }); + manager.setupSocketListeners(); + + manager.requestNextDirectoryPage('left'); + listeners.directory_listing({ + source_id: 'sftp-session:session-a', + request_id: emitted[0].payload.request_id, + path: '/srv', + cursor: token, + files: [{ name: 'two' }], + next_cursor: null, + }); + + assert.equal(list.scrollTop, 144); + assert.equal(list.getAttribute('tabindex'), '-1'); + assert.deepEqual(list.focusOptions, { preventScroll: true }); + assert.equal(global.document.activeElement, list); + } finally { + global.document = previousDocument; + } +}); + +test('a stale pane timeout cannot finish a newer hidden directory request', () => { + const previousSetTimeout = global.setTimeout; + const previousClearTimeout = global.clearTimeout; + const timers = []; + const cleared = []; + global.setTimeout = callback => { + const timer = { callback }; + timers.push(timer); + return timer; + }; + global.clearTimeout = timer => { cleared.push(timer); }; + + try { + const manager = Object.create(SFTPFileManager.prototype); + const state = filePane(manager, 'sftp-session:session-a', { path: '/srv' }); + const emitted = []; + let renders = 0; + let notifications = 0; + Object.assign(manager, { + requestSequence: 0, + socket: { + emit(event, payload) { emitted.push({ event, payload }); }, + }, + panes: { left: state, right: manager.createEmptyPaneState() }, + renderPane() { renders += 1; }, + showNotification() { notifications += 1; }, + t(_key, fallback) { return fallback; }, + }); + + const firstRequestId = manager.requestDirectoryForState('left', state, '/srv'); + assert.equal(manager.setLoadingTimeout('left'), true); + const firstTimer = timers[0]; + assert.equal(state.loadingTimeout, firstTimer); + + const secondRequestId = manager.requestDirectoryForState('left', state, '/srv'); + assert.notEqual(secondRequestId, firstRequestId); + assert.equal(state.pendingDirectoryRequestId, secondRequestId); + assert.deepEqual(cleared, [firstTimer]); + assert.equal(state.loadingTimeout, null); + + // A callback already queued by the runtime may still run after clearTimeout. + firstTimer.callback(); + + assert.equal(state.loading, true); + assert.equal(state.pendingDirectoryRequestId, secondRequestId); + assert.equal(state.pendingDirectoryPath, '/srv'); + assert.equal(state.pendingDirectoryCursor, 0); + assert.equal(state.error, null); + assert.equal(renders, 0); + assert.equal(notifications, 0); + assert.deepEqual(emitted.map(item => item.event), [ + 'list_directory', + 'cancel_directory_listing', + 'list_directory', + ]); + assert.equal(emitted[1].payload.listing_request_id, firstRequestId); + } finally { + global.setTimeout = previousSetTimeout; + global.clearTimeout = previousClearTimeout; + } +}); + +test('the current pane timeout still terminates only its correlated request', () => { + const previousSetTimeout = global.setTimeout; + let callback = null; + global.setTimeout = handler => { + callback = handler; + return { current: true }; + }; + + try { + const manager = Object.create(SFTPFileManager.prototype); + const state = filePane(manager, 'sftp-session:session-a', { path: '/srv' }); + let renders = 0; + let notifications = 0; + Object.assign(manager, { + requestSequence: 0, + socket: { emit() {} }, + panes: { left: state, right: manager.createEmptyPaneState() }, + renderPane() { renders += 1; }, + showNotification() { notifications += 1; }, + t(_key, fallback) { return fallback; }, + }); + + const requestId = manager.requestDirectoryForState('left', state, '/srv'); + assert.equal(manager.setLoadingTimeout('left'), true); + assert.equal(typeof callback, 'function'); + + callback(); + + assert.equal(state.loading, false); + assert.equal(state.loadingMore, false); + assert.equal(state.loadingTimeout, null); + assert.equal(state.pendingDirectoryRequestId, null); + assert.equal(state.pendingDirectoryPath, null); + assert.equal(state.pendingDirectoryCursor, 0); + assert.equal(state.error, 'Connection timeout - could not load directory'); + assert.equal(requestId.startsWith('left:directory:'), true); + assert.equal(renders, 1); + assert.equal(notifications, 1); + } finally { + global.setTimeout = previousSetTimeout; + } +}); + +test('a pane continuation timeout cancels its cursor and restarts page zero once', () => { + const previousSetTimeout = global.setTimeout; + const previousClearTimeout = global.clearTimeout; + const timers = []; + global.setTimeout = callback => { + const timer = { callback, cleared: false }; + timers.push(timer); + return timer; + }; + global.clearTimeout = timer => { timer.cleared = true; }; + + try { + const token = `v1.abcdefghijklmnop.1.${'e'.repeat(32)}`; + const emitted = []; + const manager = Object.create(SFTPFileManager.prototype); + const state = filePane(manager, 'sftp-session:session-a', { + path: '/srv', + files: [{ name: 'first-page' }], + nextDirectoryCursor: token, + }); + Object.assign(manager, { + requestSequence: 0, + socket: { emit(event, payload) { emitted.push({ event, payload }); } }, + isOpen: true, + displayMode: 'embedded', + panes: { left: state, right: manager.createEmptyPaneState() }, + renderPane() {}, + showNotification() {}, + t(_key, fallback) { return fallback; }, + }); + + assert.equal(manager.requestNextDirectoryPage('left'), true); + assert.equal(timers.length, 1); + timers[0].callback(); + + assert.deepEqual(emitted.map(item => item.event), [ + 'list_directory', + 'cancel_directory_listing', + 'list_directory', + ]); + assert.equal(emitted[1].payload.cursor, token); + assert.equal(emitted[2].payload.cursor, 0); + assert.equal(state.loading, true); + assert.equal(state.loadingMore, false); + assert.equal(state.nextDirectoryCursor, null); + assert.equal(state.pendingDirectoryCursor, 0); + assert.equal(timers.length, 2); + const recoveryRequestId = state.pendingDirectoryRequestId; + + timers[1].callback(); + + assert.equal(state.loading, false); + assert.equal(state.pendingDirectoryRequestId, null); + assert.equal(emitted.filter(item => item.event === 'list_directory').length, 2); + assert.equal(emitted.filter( + item => item.event === 'cancel_directory_listing', + ).length, 2); + assert.equal( + emitted.at(-1).payload.listing_request_id, + recoveryRequestId, + ); + } finally { + global.setTimeout = previousSetTimeout; + global.clearTimeout = previousClearTimeout; + } +}); + +test('pane synchronous continuation errors include the cursor and stale cursor errors are ignored', () => { + const previousDocument = global.document; + global.document = { getElementById: () => null }; + const listeners = {}; + const emitted = []; + const localErrors = []; + const token = `v1.abcdefghijklmnop.1.${'a'.repeat(32)}`; + try { + const manager = Object.create(SFTPFileManager.prototype); + const state = filePane(manager, 'sftp-session:session-a', { + path: '/srv', + files: [{ name: 'one' }], + nextDirectoryCursor: token, + }); + Object.assign(manager, { + requestSequence: 0, + socket: { + on(event, callback) { listeners[event] = callback; }, + emit(event, payload) { + emitted.push({ event, payload }); + if (payload.cursor === token) throw new Error('socket unavailable'); + }, + }, + isOpen: true, + displayMode: 'embedded', + panes: { left: state, right: manager.createEmptyPaneState() }, + renderPane() {}, + setLoadingTimeout() {}, + showNotification() {}, + t(_key, fallback) { return fallback; }, + }); + const consumePaneDirectoryError = manager.consumePaneDirectoryError.bind(manager); + manager.consumePaneDirectoryError = (data, message) => { + localErrors.push(data); + return consumePaneDirectoryError(data, message); + }; + manager.setupSocketListeners(); + + assert.doesNotThrow(() => manager.requestNextDirectoryPage('left')); + assert.equal(localErrors[0].cursor, token); + const listingRequests = emitted.filter(item => item.event === 'list_directory'); + assert.equal(listingRequests.length, 2); + assert.equal(listingRequests[0].payload.cursor, token); + assert.equal(listingRequests[1].payload.cursor, 0); + assert.equal(emitted.some(item => ( + item.event === 'cancel_directory_listing' + && item.payload.cursor === token + )), true); + const recoveryRequestId = listingRequests[1].payload.request_id; + assert.equal(state.loading, true); + assert.equal(state.pendingDirectoryRequestId, recoveryRequestId); + + listeners.error({ + operation: 'list_directory', + source_id: 'sftp-session:session-a', + request_id: recoveryRequestId, + path: '/srv', + cursor: token, + error: 'stale continuation error', + }); + assert.equal(state.loading, true); + assert.equal(state.pendingDirectoryRequestId, recoveryRequestId); + + listeners.error({ + operation: 'list_directory', + source_id: 'sftp-session:session-a', + request_id: recoveryRequestId, + path: '/srv', + cursor: 0, + error: 'current page-zero error', + }); + assert.equal(state.loading, false); + assert.equal(state.error, 'current page-zero error'); + } finally { + global.document = previousDocument; + } +}); + +test('synchronous initial pane emit failure reaches the error state without arming a timeout', () => { + const previousDocument = global.document; + global.document = { getElementById: () => null }; + try { + const manager = Object.create(SFTPFileManager.prototype); + const state = filePane(manager, 'sftp-session:session-a', { path: '/srv' }); + let timeoutCalls = 0; + Object.assign(manager, { + requestSequence: 0, + socket: { emit() { throw new Error('socket unavailable'); } }, + isOpen: true, + displayMode: 'embedded', + panes: { left: state, right: manager.createEmptyPaneState() }, + renderPane() {}, + setLoadingTimeout() { timeoutCalls += 1; }, + showNotification() {}, + t(_key, fallback) { return fallback; }, + }); + + assert.doesNotThrow(() => manager.navigatePaneTo('left', '/next')); + assert.equal(timeoutCalls, 0); + assert.equal(state.loading, false); + assert.equal(state.pendingDirectoryRequestId, null); + assert.equal(state.error, 'This file source is no longer available'); + } finally { + global.document = previousDocument; + } +}); + +test('a failed continuation restarts once from page zero', () => { + const listeners = {}; + const emitted = []; + const notifications = []; + const token = `v1.abcdefghijklmnop.1.${'a'.repeat(32)}`; + const manager = Object.create(SFTPFileManager.prototype); + const state = filePane(manager, 'sftp-session:session-a', { + path: '/srv', + files: [{ name: 'stale-page' }], + loadingMore: true, + nextDirectoryCursor: token, + pendingDirectoryRequestId: 'left:directory-page:2', + pendingDirectoryPath: '/srv', + pendingDirectoryCursor: token, + directoryContinuationView: { + requestId: 'left:directory-page:2', cursor: token, + scrollTop: 240, restoreFocus: true, + }, + }); + let renders = 0; + let timeouts = 0; + Object.assign(manager, { + requestSequence: 2, + socket: { + on(event, callback) { listeners[event] = callback; }, + emit(event, payload) { emitted.push({ event, payload }); }, + }, + isOpen: true, + panes: { left: state, right: manager.createEmptyPaneState() }, + updatePathInput() {}, + renderPane() { renders += 1; }, + setLoadingTimeout() { timeouts += 1; }, + showNotification(message, level) { notifications.push([message, level]); }, + }); + manager.setupSocketListeners(); + + listeners.error({ + operation: 'list_directory', + source_id: 'sftp-session:session-a', + request_id: 'left:directory-page:2', + path: '/srv', + cursor: token, + error: 'Failed to list directory: listing expired', + }); - assert.deepEqual(emitted, [[ - 'file_source_disconnect', { source_id: 'smb-quick:owned' }, + const listingRequest = emitted.find(item => item.event === 'list_directory'); + const cancellations = emitted.filter( + item => item.event === 'cancel_directory_listing', + ); + assert.equal(cancellations.length, 1); + assert.equal(cancellations[0].payload.cursor, token); + assert.equal(listingRequest.payload.source_id, 'sftp-session:session-a'); + assert.equal(listingRequest.payload.remote_path, '/srv'); + assert.equal(listingRequest.payload.cursor, 0); + assert.notEqual(listingRequest.payload.request_id, 'left:directory-page:2'); + assert.equal(state.loading, true); + assert.equal(state.loadingMore, false); + assert.equal(state.nextDirectoryCursor, null); + assert.equal(state.pendingDirectoryRequestId, listingRequest.payload.request_id); + assert.equal(state.pendingDirectoryCursor, 0); + assert.equal(state.directoryContinuationView, null); + assert.deepEqual(state.files, [{ name: 'stale-page' }]); + assert.equal(renders, 1); + assert.equal(timeouts, 1); + assert.deepEqual(notifications, [[ + 'Failed to list directory: listing expired', 'error', ]]); - assert.deepEqual(manager.smbSources, []); + + listeners.directory_listing({ + source_id: 'sftp-session:session-a', + request_id: listingRequest.payload.request_id, + path: '/srv', + cursor: 0, + files: [{ name: 'fresh-page' }], + next_cursor: null, + }); + + assert.deepEqual(state.files, [{ name: 'fresh-page' }]); + assert.equal(state.loading, false); + assert.equal(state.nextDirectoryCursor, null); }); -test('a correlated listing updates an inactive source tab without replacing the visible tab', () => { +test('a hidden continuation recovery keeps an exact state-bound timeout', () => { + const previousSetTimeout = global.setTimeout; + const previousClearTimeout = global.clearTimeout; + const timers = []; + global.setTimeout = callback => { + const timer = { callback, cleared: false }; + timers.push(timer); + return timer; + }; + global.clearTimeout = timer => { timer.cleared = true; }; + + try { + const listeners = {}; + const emitted = []; + const token = `v1.abcdefghijklmnop.1.${'f'.repeat(32)}`; + const manager = Object.create(SFTPFileManager.prototype); + manager.initializeWorkspaceState(); + const hiddenState = filePane(manager, 'sftp-session:hidden', { + path: '/srv/hidden', + files: [{ name: 'stale-page' }], + loadingMore: true, + nextDirectoryCursor: token, + pendingDirectoryRequestId: 'left:directory-page:hidden', + pendingDirectoryPath: '/srv/hidden', + pendingDirectoryCursor: token, + }); + const activeState = filePane(manager, 'sftp-session:active', { + path: '/srv/active', + files: [{ name: 'visible.txt' }], + }); + const hiddenTab = manager.workspace.openTab( + 'left', + hiddenState.source, + hiddenState, + ); + manager.workspace.openTab( + 'left', + activeState.source, + activeState, + ); + manager.syncPaneFromWorkspace('left'); + Object.assign(manager, { + requestSequence: 0, + socket: { + on(event, callback) { listeners[event] = callback; }, + emit(event, payload) { emitted.push({ event, payload }); }, + }, + isOpen: true, + displayMode: 'modal', + setActivePane() {}, + updatePathInput() {}, + updatePaneBadge() {}, + renderPane() {}, + renderWorkspaceChrome() {}, + showNotification() {}, + t(_key, fallback) { return fallback; }, + }); + manager.setupSocketListeners(); + + listeners.error({ + operation: 'list_directory', + source_id: 'sftp-session:hidden', + request_id: 'left:directory-page:hidden', + path: '/srv/hidden', + cursor: token, + error: 'Failed to list directory: listing expired', + }); + + const recovery = emitted.find( + item => item.event === 'list_directory', + ).payload; + assert.equal(hiddenState.loading, true); + assert.equal(hiddenState.loadingMore, false); + assert.equal(hiddenState.pendingDirectoryRequestId, recovery.request_id); + assert.equal(timers.length, 1); + assert.equal(hiddenState.loadingTimeout, timers[0]); + assert.equal(activeState.loadingTimeout, null); + assert.equal(manager.panes.left, activeState); + + timers[0].callback(); + + assert.equal(hiddenState.loading, false); + assert.equal(hiddenState.loadingTimeout, null); + assert.equal( + hiddenState.error, + 'Connection timeout - could not load directory', + ); + assert.equal(activeState.files[0].name, 'visible.txt'); + const requestCancellation = emitted.find(item => ( + item.event === 'cancel_directory_listing' + && item.payload.listing_request_id === recovery.request_id + )); + assert.ok(requestCancellation); + + manager.activateSourceTab('left', hiddenTab.id); + assert.equal(manager.panes.left, hiddenState); + assert.equal(manager.panes.left.loading, false); + } finally { + global.setTimeout = previousSetTimeout; + global.clearTimeout = previousClearTimeout; + } +}); + +test('a failed page-zero recovery is not retried again', () => { const listeners = {}; + const emitted = []; + const token = `v1.abcdefghijklmnop.1.${'a'.repeat(32)}`; const manager = Object.create(SFTPFileManager.prototype); - manager.initializeWorkspaceState(); - const inactiveState = filePane(manager, 'sftp-session:session-a', { - loading: true, - pendingDirectoryRequestId: 'left:directory:1', pendingDirectoryPath: '/srv/a', - }); - const activeState = filePane(manager, 'sftp-session:session-b', { - path: '/srv/b', - files: [{ name: 'visible.txt' }], + const state = filePane(manager, 'sftp-session:session-a', { + path: '/srv', + loadingMore: true, + nextDirectoryCursor: token, + pendingDirectoryRequestId: 'left:directory-page:2', + pendingDirectoryPath: '/srv', + pendingDirectoryCursor: token, }); - manager.workspace.openTab( - 'left', fileSource('sftp-session:session-a', { label: 'A' }), - inactiveState, - ); - const activeTab = manager.workspace.openTab( - 'left', fileSource('sftp-session:session-b', { label: 'B' }), - activeState, - ); - manager.syncPaneFromWorkspace('left'); Object.assign(manager, { - socket: { on(event, callback) { listeners[event] = callback; } }, + requestSequence: 2, + socket: { + on(event, callback) { listeners[event] = callback; }, + emit(event, payload) { emitted.push({ event, payload }); }, + }, isOpen: true, - displayMode: 'modal', - updatePathInput() { assert.fail('inactive tab changed the visible path'); }, - renderPane() { assert.fail('inactive tab re-rendered the visible pane'); }, + panes: { left: state, right: manager.createEmptyPaneState() }, + renderPane() {}, + setLoadingTimeout() {}, + showNotification() {}, }); manager.setupSocketListeners(); - listeners.directory_listing({ - source_id: 'sftp-session:session-a', request_id: 'left:directory:1', path: '/srv/a', - files: [{ name: 'late.txt' }], + listeners.error({ + operation: 'list_directory', + source_id: 'sftp-session:session-a', + request_id: 'left:directory-page:2', + path: '/srv', + cursor: token, + error: 'Failed to list directory: listing expired', }); + const recoveryRequest = emitted.find( + item => item.event === 'list_directory', + ).payload; - assert.equal(manager.workspace.getActiveTab('left'), activeTab); - assert.equal(manager.panes.left, activeState); - assert.equal(manager.panes.left.files[0].name, 'visible.txt'); - assert.equal(inactiveState.loading, false); - assert.equal(inactiveState.files[0].name, 'late.txt'); + listeners.error({ + operation: 'list_directory', + source_id: 'sftp-session:session-a', + request_id: recoveryRequest.request_id, + path: '/srv', + error: 'Failed to list directory: backend unavailable', + }); + + assert.equal(emitted.filter(item => item.event === 'list_directory').length, 1); + const cancellations = emitted.filter( + item => item.event === 'cancel_directory_listing', + ); + assert.equal(cancellations.length, 2); + assert.equal(cancellations[0].payload.cursor, token); + assert.equal( + cancellations[1].payload.listing_request_id, + recoveryRequest.request_id, + ); + assert.equal(state.loading, false); + assert.equal(state.loadingMore, false); + assert.equal(state.nextDirectoryCursor, null); + assert.equal(state.pendingDirectoryRequestId, null); + assert.equal(state.error, 'Failed to list directory: backend unavailable'); }); test('single-pane workspace can activate either side even on a narrow viewport', () => { @@ -1662,6 +2701,7 @@ test('Move picker accepts only its correlated directory listing and shows folder request_id: requests[0].payload.request_id, files: [], }), false); + const token = `v1.abcdefghijklmnop.2.${'b'.repeat(32)}`; assert.equal(manager.consumeMovePickerListing({ source_id: 'sftp-session:shared', path: '/source', @@ -1673,6 +2713,7 @@ test('Move picker accepts only its correlated directory listing and shows folder { name: 'nested/name', is_dir: true }, { name: 'a-first', is_dir: true }, ], + next_cursor: token, }), true); assert.equal(manager.movePicker.loading, false); assert.equal(manager.movePicker.validTarget, true); @@ -1680,6 +2721,443 @@ test('Move picker accepts only its correlated directory listing and shows folder { name: 'a-first', path: '/source/a-first' }, { name: 'z-last', path: '/source/z-last' }, ]); + assert.equal(manager.movePicker.nextCursor, token); + + assert.equal(manager.requestMovePickerDirectory('/source', token), true); + assert.equal(requests[1].payload.cursor, token); + assert.equal(manager.consumeMovePickerListing({ + source_id: 'sftp-session:shared', + path: '/source', + request_id: requests[1].payload.request_id, + cursor: token, + files: [{ name: 'middle', is_dir: true }], + next_cursor: null, + }), true); + assert.deepEqual(manager.movePicker.directories, [ + { name: 'a-first', path: '/source/a-first' }, + { name: 'middle', path: '/source/middle' }, + { name: 'z-last', path: '/source/z-last' }, + ]); + assert.equal(manager.movePicker.nextCursor, null); +}); + +test('closing the Move picker retires its exact directory snapshot', () => { + const emitted = []; + const token = `v1.abcdefghijklmnop.2.${'d'.repeat(32)}`; + let removed = false; + const manager = Object.create(SFTPFileManager.prototype); + Object.assign(manager, { + requestSequence: 0, + socket: { emit(event, payload) { emitted.push({ event, payload }); } }, + movePicker: { + sourceId: 'sftp-session:shared', + nextCursor: token, + pendingCursor: 0, + element: { remove() { removed = true; } }, + previousFocus: null, + }, + }); + + assert.equal(manager.closeMovePicker(), true); + + assert.equal(removed, true); + assert.equal(manager.movePicker, null); + assert.equal(emitted.length, 1); + assert.equal(emitted[0].event, 'cancel_directory_listing'); + assert.equal(emitted[0].payload.cursor, token); +}); + +test('closing the Move picker cancels its pending page-zero request', () => { + const emitted = []; + let removed = false; + const manager = Object.create(SFTPFileManager.prototype); + Object.assign(manager, { + requestSequence: 0, + socket: { emit(event, payload) { emitted.push({ event, payload }); } }, + movePicker: { + sourceId: 'sftp-session:shared', + pendingRequestId: 'move-picker:directory:pending', + pendingCursor: 0, + nextCursor: null, + element: { remove() { removed = true; } }, + previousFocus: null, + }, + }); + + assert.equal(manager.closeMovePicker(), true); + + assert.equal(removed, true); + assert.equal(manager.movePicker, null); + assert.equal(emitted.length, 1); + assert.equal(emitted[0].event, 'cancel_directory_listing'); + assert.equal( + emitted[0].payload.listing_request_id, + 'move-picker:directory:pending', + ); +}); + +test('Move picker continuation preserves rows, scroll and focus through the final page', () => { + const previousDocument = global.document; + const requests = []; + const firstToken = `v1.abcdefghijklmnop.2.${'b'.repeat(32)}`; + const secondToken = `v1.abcdefghijklmnop.3.${'c'.repeat(32)}`; + const firstLoadMore = focusableControl('Load more'); + const list = paginationList(firstLoadMore); + list.scrollTop = 226; + global.document = { activeElement: firstLoadMore }; + + try { + const manager = Object.create(SFTPFileManager.prototype); + Object.assign(manager, { + requestSequence: 0, + movePickerListingTimeoutMs: 1000, + socket: { emit(event, payload) { requests.push({ event, payload }); } }, + escapeHtml(value) { return String(value); }, + movePickerTargetReason() { return null; }, + t(_key, fallback) { return fallback; }, + transferExecutionInProgress: false, + movePicker: { + sourceId: 'sftp-session:shared', + sourceKind: 'sftp', + sourcePath: '/source', + targetPath: '/target', + homePath: '/', + selectedItems: [{ name: 'report.txt', is_dir: false }], + directories: [{ name: 'existing', path: '/target/existing' }], + nextCursor: firstToken, + loading: false, + loadingMore: false, + validTarget: true, + listingTimeout: null, + focusListAfterRender: false, + element: movePickerElement(list), + }, + }); + + assert.equal(manager.requestMovePickerDirectory('/target', firstToken), true); + assert.equal(list.renderCount, 0); + assert.equal(list.innerHTML, 'existing rows'); + assert.deepEqual(manager.movePicker.directories, [ + { name: 'existing', path: '/target/existing' }, + ]); + assert.equal(firstLoadMore.disabled, true); + assert.equal(firstLoadMore.getAttribute('aria-busy'), 'true'); + assert.equal(firstLoadMore.textContent, 'Loading...'); + assert.equal(list.getAttribute('aria-busy'), 'true'); + + assert.equal(manager.consumeMovePickerListing({ + source_id: 'sftp-session:shared', + request_id: requests[0].payload.request_id, + path: '/target', + cursor: firstToken, + files: [{ name: 'middle', is_dir: true }], + next_cursor: secondToken, + }), true); + const replacementLoadMore = list.currentLoadMore; + assert.notEqual(replacementLoadMore, firstLoadMore); + assert.equal(list.scrollTop, 226); + assert.deepEqual(replacementLoadMore.focusOptions, { preventScroll: true }); + assert.equal(global.document.activeElement, replacementLoadMore); + + list.scrollTop = 407; + assert.equal(manager.requestMovePickerDirectory('/target', secondToken), true); + assert.equal(list.renderCount, 1); + assert.equal(replacementLoadMore.disabled, true); + assert.equal(replacementLoadMore.getAttribute('aria-busy'), 'true'); + + assert.equal(manager.consumeMovePickerListing({ + source_id: 'sftp-session:shared', + request_id: requests[1].payload.request_id, + path: '/target', + cursor: secondToken, + files: [{ name: 'z-last', is_dir: true }], + next_cursor: null, + }), true); + assert.equal(list.currentLoadMore, null); + assert.equal(list.scrollTop, 407); + assert.equal(list.getAttribute('tabindex'), '-1'); + assert.deepEqual(list.focusOptions, { preventScroll: true }); + assert.equal(global.document.activeElement, list); + } finally { + global.document = previousDocument; + } +}); + +test('Move picker synchronous continuation errors carry the cursor and stale errors cannot finish a newer request', () => { + const previousDocument = global.document; + global.document = { getElementById: () => null }; + const attemptedErrors = []; + const requests = []; + const token = `v1.abcdefghijklmnop.2.${'b'.repeat(32)}`; + let throwOnEmit = true; + try { + const manager = Object.create(SFTPFileManager.prototype); + Object.assign(manager, { + requestSequence: 0, + movePickerListingTimeoutMs: 1000, + socket: { + emit(event, payload) { + requests.push({ event, payload }); + if (throwOnEmit) throw new Error('socket unavailable'); + }, + }, + renderMovePicker() {}, + t(_key, fallback) { return fallback; }, + movePicker: { + sourceId: 'sftp-session:shared', + sourceKind: 'sftp', + sourcePath: '/source', + targetPath: '/source', + selectedItems: [{ name: 'report.txt', is_dir: false }], + directories: [{ name: 'existing', path: '/source/existing' }], + nextCursor: token, + loading: false, + loadingMore: false, + validTarget: true, + listingTimeout: null, + }, + }); + const consumeMovePickerError = manager.consumeMovePickerError.bind(manager); + manager.consumeMovePickerError = (data, message) => { + attemptedErrors.push(data); + return consumeMovePickerError(data, message); + }; + + assert.doesNotThrow(() => manager.requestMovePickerDirectory('/source', token)); + const staleError = attemptedErrors[0]; + assert.equal(staleError.cursor, token); + assert.equal(manager.movePicker.loading, false); + assert.equal(manager.movePicker.pendingRequestId, null); + + throwOnEmit = false; + manager.movePicker.directories = [{ name: 'existing', path: '/source/existing' }]; + manager.movePicker.nextCursor = token; + assert.equal(manager.requestMovePickerDirectory('/source', token), true); + const currentRequestId = requests.filter( + request => request.event === 'list_directory', + ).at(-1).payload.request_id; + assert.equal(manager.movePicker.pendingRequestId, currentRequestId); + + assert.equal(manager.consumeMovePickerError(staleError, 'stale request'), false); + assert.equal(manager.consumeMovePickerError({ + ...staleError, + request_id: currentRequestId, + cursor: 'stale-cursor', + }, 'stale cursor'), false); + assert.equal(manager.movePicker.loading, true); + assert.equal(manager.movePicker.pendingRequestId, currentRequestId); + + assert.equal(manager.consumeMovePickerError({ + ...staleError, + request_id: currentRequestId, + cursor: token, + }, 'current request'), true); + const recoveryRequest = requests.filter( + request => request.event === 'list_directory', + ).at(-1).payload; + assert.equal(recoveryRequest.cursor, 0); + assert.equal(manager.movePicker.loading, true); + assert.equal(manager.movePicker.pendingRequestId, recoveryRequest.request_id); + assert.equal(manager.consumeMovePickerListing({ + source_id: 'sftp-session:shared', + request_id: recoveryRequest.request_id, + path: '/source', + cursor: 0, + files: [], + next_cursor: null, + }), true); + } finally { + global.document = previousDocument; + } +}); + +test('Move picker consumes a correlated continuation listing error immediately', () => { + const listeners = {}; + const emitted = []; + const token = `v1.abcdefghijklmnop.2.${'b'.repeat(32)}`; + let renders = 0; + const manager = Object.create(SFTPFileManager.prototype); + Object.assign(manager, { + socket: { + on(event, callback) { listeners[event] = callback; }, + emit(event, payload) { emitted.push({ event, payload }); }, + }, + isOpen: true, + panes: {}, + displayMode: 'embedded', + renderMovePicker() { renders += 1; }, + t(_key, fallback) { return fallback; }, + movePicker: { + sourceId: 'sftp-session:shared', + sourceKind: 'sftp', + sourcePath: '/source', + targetPath: '/source', + pendingPath: '/source', + pendingRequestId: 'move-picker:directory:2', + pendingCursor: token, + nextCursor: token, + directories: [{ name: 'existing', path: '/source/existing' }], + loading: true, + loadingMore: true, + validTarget: true, + listingTimeout: null, + }, + }); + manager.setupSocketListeners(); + + listeners.error({ + operation: 'list_directory', + source_id: 'sftp-session:shared', + request_id: 'move-picker:directory:2', + path: '/source', + cursor: token, + error: 'Failed to list directory: listing expired', + }); + + const recoveryRequest = emitted.find(item => item.event === 'list_directory'); + assert.equal(manager.movePicker.loading, true); + assert.equal(manager.movePicker.loadingMore, false); + assert.equal(manager.movePicker.validTarget, false); + assert.equal(manager.movePicker.error, null); + assert.equal(manager.movePicker.continuationError, null); + assert.deepEqual(manager.movePicker.directories, []); + assert.equal(manager.movePicker.nextCursor, null); + assert.equal(manager.movePicker.pendingRequestId, recoveryRequest.payload.request_id); + assert.equal(recoveryRequest.payload.cursor, 0); + assert.equal(emitted.some(item => ( + item.event === 'cancel_directory_listing' + && item.payload.cursor === token + )), true); + assert.equal(renders, 1); + + listeners.directory_listing({ + source_id: 'sftp-session:shared', + request_id: recoveryRequest.payload.request_id, + path: '/source', + cursor: 0, + files: [{ name: 'fresh', is_dir: true }], + next_cursor: null, + }); + assert.equal(manager.movePicker.loading, false); + assert.equal(manager.movePicker.validTarget, true); + assert.deepEqual(manager.movePicker.directories, [ + { name: 'fresh', path: '/source/fresh' }, + ]); +}); + +test('Move picker continuation timeout restarts once and page-zero failure is terminal', () => { + const previousDocument = global.document; + const previousSetTimeout = global.setTimeout; + const previousClearTimeout = global.clearTimeout; + const timers = []; + global.setTimeout = callback => { + const timer = { callback, cleared: false }; + timers.push(timer); + return timer; + }; + global.clearTimeout = timer => { timer.cleared = true; }; + + const token = `v1.abcdefghijklmnop.2.${'b'.repeat(32)}`; + const oldLoadMore = focusableControl('Load more'); + const list = paginationList(oldLoadMore); + list.scrollTop = 291; + global.document = { activeElement: oldLoadMore }; + + try { + const manager = Object.create(SFTPFileManager.prototype); + Object.assign(manager, { + requestSequence: 0, + movePickerListingTimeoutMs: 1000, + socket: { emit() {} }, + escapeHtml(value) { return String(value); }, + t(_key, fallback) { return fallback; }, + transferExecutionInProgress: false, + movePicker: { + sourceId: 'sftp-session:shared', + sourceKind: 'sftp', + sourcePath: '/source', + targetPath: '/target', + homePath: '/', + selectedItems: [{ name: 'report.txt', is_dir: false }], + directories: [{ name: 'existing', path: '/target/existing' }], + nextCursor: token, + loading: false, + loadingMore: false, + validTarget: true, + listingTimeout: null, + continuationError: null, + focusListAfterRender: false, + element: movePickerElement(list), + }, + }); + + assert.equal(manager.requestMovePickerDirectory('/target', token), true); + assert.equal(timers.length, 1); + assert.equal(oldLoadMore.disabled, true); + assert.equal(list.scrollTop, 291); + + timers[0].callback(); + + assert.equal(manager.movePicker.loading, true); + assert.equal(manager.movePicker.loadingMore, false); + assert.equal(manager.movePicker.validTarget, false); + assert.equal(manager.movePicker.error, null); + assert.equal(manager.movePicker.continuationError, null); + assert.deepEqual(manager.movePicker.directories, []); + assert.equal(manager.movePicker.nextCursor, null); + assert.equal(timers.length, 2); + + timers[1].callback(); + + assert.equal(manager.movePicker.loading, false); + assert.equal(manager.movePicker.pendingRequestId, null); + assert.equal(timers.length, 2); + const status = manager.movePicker.element.querySelector('[data-move-picker-status]'); + assert.equal(status.dataset.state, 'error'); + assert.equal(status.textContent, 'The destination folder could not be opened.'); + } finally { + global.document = previousDocument; + global.setTimeout = previousSetTimeout; + global.clearTimeout = previousClearTimeout; + } +}); + +test('Move picker initial listing failure still invalidates and clears the target', () => { + const manager = Object.create(SFTPFileManager.prototype); + Object.assign(manager, { + renderMovePicker() {}, + t(_key, fallback) { return fallback; }, + movePicker: { + sourceId: 'sftp-session:shared', + sourceKind: 'sftp', + sourcePath: '/source', + targetPath: '/old-target', + pendingPath: '/new-target', + pendingRequestId: 'move-picker:directory:3', + pendingCursor: 0, + nextCursor: 'stale-cursor', + directories: [{ name: 'stale', path: '/old-target/stale' }], + loading: true, + loadingMore: false, + validTarget: true, + listingTimeout: null, + }, + }); + + assert.equal(manager.consumeMovePickerError({ + operation: 'list_directory', + source_id: 'sftp-session:shared', + request_id: 'move-picker:directory:3', + path: '/new-target', + cursor: 0, + }, 'Initial listing failed'), true); + + assert.equal(manager.movePicker.loading, false); + assert.equal(manager.movePicker.validTarget, false); + assert.equal(manager.movePicker.error, 'Initial listing failed'); + assert.deepEqual(manager.movePicker.directories, []); + assert.equal(manager.movePicker.nextCursor, null); + assert.equal(manager.movePicker.pendingRequestId, null); }); test('Move picker correlates SMB listing paths case-insensitively', () => { @@ -2775,6 +4253,7 @@ test('upload completion refreshes its original tab after another tab becomes act source_id: 'sftp-session:upload-session', remote_path: '/srv/upload', request_id: 'left:directory:1', + cursor: 0, }, }]); assert.equal(destinationState.loading, true); @@ -2827,6 +4306,7 @@ test('upload completion refreshes and caches its original tab while the workspac source_id: 'sftp-session:upload-session', remote_path: '/srv/upload', request_id: 'left:directory:1', + cursor: 0, }, }]); @@ -3621,3 +5101,13 @@ test('structured byte limit renders the exact localized size and limit kind', () { limit_kind: 'raw', limit_bytes: -1, actual_bytes: true }, ), 'The transfer exceeds the configured limit.'); }); + +test('source identity changes keep their actionable transfer message', () => { + const manager = Object.create(SFTPFileManager.prototype); + manager.t = (_key, fallback) => fallback; + + assert.equal(manager.transferFailureMessage( + 'SOURCE_CHANGED', + 'The source changed during the transfer. Try again.', + ), 'The source changed during the transfer. Try again.'); +}); diff --git a/tests/js/smb-source-dialog.test.js b/tests/js/smb-source-dialog.test.js index 7ad6e13a..5e0acfc6 100644 --- a/tests/js/smb-source-dialog.test.js +++ b/tests/js/smb-source-dialog.test.js @@ -253,6 +253,7 @@ for (const [code, message] of [ ['PERMISSION_DENIED', 'You do not have permission to open this SMB share.'], ['SHARE_UNAVAILABLE', 'The SMB share could not be found or opened.'], ['TIMEOUT', 'The SMB server did not respond in time.'], + ['IDENTITY_UNAVAILABLE', 'This SMB server cannot provide the stable file identities required for secure access.'], ]) { test(`${code} shows its actionable connection reason`, () => { const { dialog, elements } = harness(); diff --git a/tests/js/socket-protocol.test.js b/tests/js/socket-protocol.test.js new file mode 100644 index 00000000..3a24b2dd --- /dev/null +++ b/tests/js/socket-protocol.test.js @@ -0,0 +1,399 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const SocketProtocol = require('../../static/js/socket-protocol.js'); +const ROOT = path.resolve(__dirname, '..', '..'); + +function createStorage() { + const values = new Map(); + return { + getItem(key) { + return values.has(key) ? values.get(key) : null; + }, + removeItem(key) { + values.delete(key); + }, + setItem(key, value) { + values.set(key, String(value)); + }, + }; +} + +function createElement(tagName = 'div') { + const listeners = new Map(); + const classes = new Set(); + const element = { + tagName: tagName.toUpperCase(), + children: [], + className: '', + textContent: '', + classList: { + add(...names) { + names.forEach(name => classes.add(name)); + }, + contains(name) { + return classes.has(name); + }, + }, + addEventListener(event, handler) { + listeners.set(event, handler); + }, + appendChild(child) { + child.parentNode = element; + element.children.push(child); + return child; + }, + click() { + listeners.get('click')?.(); + }, + remove() { + if (!element.parentNode) return; + const siblings = element.parentNode.children; + const index = siblings.indexOf(element); + if (index !== -1) siblings.splice(index, 1); + element.parentNode = null; + }, + setAttribute() {}, + }; + return element; +} + +function loadAppSocketHarness() { + const handlers = new Map(); + const windowHandlers = new Map(); + const emitted = []; + const notificationContainer = createElement('div'); + const state = { disconnects: 0, reloads: 0, unloads: [] }; + const socket = { + connected: true, + disconnect() { + state.disconnects += 1; + }, + emit(event) { + emitted.push(event); + }, + io: { on() {} }, + on(event, handler) { + handlers.set(event, handler); + }, + }; + const browserGlobal = { + addEventListener(event, handler) { + windowHandlers.set(event, [ + ...(windowHandlers.get(event) || []), + handler, + ]); + }, + clearInterval() {}, + clearTimeout() {}, + console: { error() {}, log() {} }, + ConnectionLauncher: { + createConnectionLauncher: () => ({ launch() {} }), + }, + document: { + addEventListener() {}, + createElement, + getElementById(id) { + return id === 'notificationContainer' + ? notificationContainer + : null; + }, + querySelector(selector) { + return selector === 'meta[name="app-root"]' + ? { content: '' } + : null; + }, + }, + io: () => socket, + location: { + reload() { + state.reloads += 1; + const event = { + defaultPrevented: false, + preventDefault() { + event.defaultPrevented = true; + }, + }; + for (const handler of windowHandlers.get('beforeunload') || []) { + handler(event); + } + state.unloads.push({ + prevented: event.defaultPrevented, + returnValue: event.returnValue, + }); + }, + }, + SessionManager: { sessions: {} }, + sessionStorage: createStorage(), + setInterval: () => 1, + setTimeout: () => 1, + URL, + }; + browserGlobal.window = browserGlobal; + const context = vm.createContext(browserGlobal); + for (const filename of [ + 'socket-reconnect-policy.js', + 'socket-protocol.js', + 'app.js', + ]) { + vm.runInContext( + fs.readFileSync(path.join(ROOT, 'static', 'js', filename), 'utf8'), + context, + ); + } + return { + browserGlobal, + emitted, + handlers, + notificationContainer, + state, + windowHandlers, + }; +} + +test('browser and server use the same hardcoded socket wire revision', () => { + const pythonSource = fs.readFileSync( + path.join(ROOT, 'app', 'socket_protocol.py'), + 'utf8', + ); + const revision = pythonSource.match(/^SOCKET_WIRE_REVISION = (\d+)$/m); + const event = pythonSource.match( + /^SOCKET_PROTOCOL_MISMATCH_EVENT = '([^']+)'$/m, + ); + + assert.ok(revision); + assert.equal(SocketProtocol.WIRE_REVISION, Number(revision[1])); + assert.ok(event); + assert.equal(SocketProtocol.MISMATCH_EVENT, event[1]); +}); + +test('server compatibility requires an exact successful revision', () => { + assert.equal(SocketProtocol.isCompatibleServer({ status: 'success' }), false); + assert.equal(SocketProtocol.isCompatibleServer({ + status: 'success', + wire_revision: SocketProtocol.WIRE_REVISION - 1, + }), false); + assert.equal(SocketProtocol.isCompatibleServer({ + status: 'success', + wire_revision: SocketProtocol.WIRE_REVISION, + }), true); +}); + +test('app requests notepad only from a server with the exact revision', () => { + for (const payload of [ + { status: 'success', username: 'legacy' }, + { + status: 'success', + username: 'old', + wire_revision: SocketProtocol.WIRE_REVISION - 1, + }, + ]) { + const harness = loadAppSocketHarness(); + harness.handlers.get('connected')(payload); + assert.equal(harness.state.disconnects, 1); + assert.equal(harness.state.reloads, 1); + assert.equal(harness.emitted.includes('get_notepad'), false); + } + + const current = loadAppSocketHarness(); + current.handlers.get('connected')({ + status: 'success', + username: 'current', + wire_revision: SocketProtocol.WIRE_REVISION, + }); + assert.equal(current.state.disconnects, 0); + assert.equal(current.state.reloads, 0); + assert.equal(current.emitted.filter(event => event === 'get_notepad').length, 1); +}); + +test('protocol reload bypasses the active-session unload warning exactly once', () => { + const harness = loadAppSocketHarness(); + harness.browserGlobal.SessionManager.sessions = { + active: { connected: true }, + }; + + harness.handlers.get('connected')({ + status: 'success', + username: 'old', + wire_revision: SocketProtocol.WIRE_REVISION - 1, + }); + + assert.equal(harness.state.disconnects, 1); + assert.equal(harness.state.reloads, 1); + assert.equal(harness.state.unloads[0].prevented, false); + assert.equal(harness.state.unloads[0].returnValue, undefined); + const beforeUnload = harness.windowHandlers.get('beforeunload')?.at(-1); + assert.equal(typeof beforeUnload, 'function'); + + let normalPrevented = 0; + const normalEvent = { + preventDefault() { normalPrevented += 1; }, + }; + const warning = beforeUnload(normalEvent); + assert.equal(normalPrevented, 1); + assert.equal(normalEvent.returnValue, warning); + assert.match(warning, /active SSH sessions/); + + const appSource = fs.readFileSync( + path.join(ROOT, 'static', 'js', 'app.js'), + 'utf8', + ); + assert.match(appSource, /reload: reloadForSocketProtocolMismatch/); + assert.match(appSource, /onClick: reloadForSocketProtocolMismatch/); +}); + +test('cancelled dirty-editor reload keeps the protocol recovery action', () => { + const harness = loadAppSocketHarness(); + harness.browserGlobal.SessionManager.sessions = { + active: { connected: true }, + }; + harness.browserGlobal.addEventListener('beforeunload', (event) => { + event.preventDefault(); + event.returnValue = ''; + }); + + harness.handlers.get('connected')({ + status: 'success', + username: 'old', + wire_revision: SocketProtocol.WIRE_REVISION - 1, + }); + + assert.equal(harness.state.disconnects, 1); + assert.equal(harness.state.reloads, 1); + assert.equal(harness.state.unloads[0].prevented, true); + assert.equal(harness.notificationContainer.children.length, 1); + + const notification = harness.notificationContainer.children[0]; + const action = notification.children.find( + child => child.tagName === 'BUTTON', + ); + assert.ok(action); + + action.click(); + + assert.equal(harness.state.reloads, 2); + assert.equal(harness.state.unloads[1].prevented, true); + assert.equal(harness.notificationContainer.children.length, 1); + assert.equal(notification.classList.contains('fade-out'), false); +}); + +test('unrelated connection errors do not trigger a protocol reload', () => { + const harness = loadAppSocketHarness(); + + harness.handlers.get('connect_error')({ + data: { code: 'capacity_unavailable' }, + }); + + assert.equal(harness.state.disconnects, 0); + assert.equal(harness.state.reloads, 0); +}); + +test('authenticated page sends the revision and loads protocol before app', () => { + const appSource = fs.readFileSync( + path.join(ROOT, 'static', 'js', 'app.js'), + 'utf8', + ); + const template = fs.readFileSync( + path.join(ROOT, 'templates', 'index.html'), + 'utf8', + ); + + assert.ok(appSource.includes( + 'auth: { wire_revision: socketProtocol.WIRE_REVISION }', + )); + assert.ok(appSource.includes("socket.on('connect_error'")); + assert.ok(appSource.includes( + "error?.data?.code !== 'socket_protocol_mismatch'", + )); + assert.ok( + template.indexOf("filename='js/socket-protocol.js'") + < template.indexOf("filename='js/app.js'"), + ); +}); + +test('mismatch reloads once then requires a persistent manual reload', () => { + const storage = createStorage(); + let disconnects = 0; + let reloads = 0; + let manualReloads = 0; + const options = { + storage, + disconnect: () => { disconnects += 1; }, + reload: () => { reloads += 1; }, + showManualReload: () => { manualReloads += 1; }, + }; + const firstPage = SocketProtocol.createMismatchController(options); + + assert.equal(firstPage.handleMismatch({ required_revision: 2 }), 'reload'); + assert.equal(firstPage.handleMismatch({ required_revision: 2 }), 'ignored'); + assert.equal(reloads, 1); + assert.equal(manualReloads, 0); + + const reloadedStalePage = SocketProtocol.createMismatchController(options); + assert.equal( + reloadedStalePage.handleMismatch({ required_revision: 2 }), + 'manual', + ); + assert.equal( + reloadedStalePage.handleMismatch({ required_revision: 2 }), + 'ignored', + ); + assert.equal(disconnects, 2); + assert.equal(reloads, 1); + assert.equal(manualReloads, 1); + + const appSource = fs.readFileSync( + path.join(ROOT, 'static', 'js', 'app.js'), + 'utf8', + ); + assert.match( + appSource, + /function showSocketProtocolReloadNotice\(\)[\s\S]+persistent: true,[\s\S]+connection\.reloadPage/, + ); + assert.match( + appSource, + /showManualReload: showSocketProtocolReloadNotice/, + ); +}); + +test('compatible backend does not re-arm reload during a rolling deployment', () => { + const storage = createStorage(); + let reloads = 0; + let manualReloads = 0; + const options = { + storage, + reload: () => { reloads += 1; }, + showManualReload: () => { manualReloads += 1; }, + }; + const firstPage = SocketProtocol.createMismatchController(options); + + assert.equal(firstPage.handleMismatch({ required_revision: 2 }), 'reload'); + + const reloadedPage = SocketProtocol.createMismatchController(options); + reloadedPage.markCompatible(); + assert.equal( + reloadedPage.handleMismatch({ required_revision: 2 }), + 'manual', + ); + assert.equal(reloads, 1); + assert.equal(manualReloads, 1); +}); + +test('restricted session storage degrades to the manual reload action', () => { + let manualReloads = 0; + const controller = SocketProtocol.createMismatchController({ + storage: { + getItem() { + throw new Error('storage denied'); + }, + }, + reload: () => assert.fail('automatic reload must remain guarded'), + showManualReload: () => { manualReloads += 1; }, + }); + + assert.equal(controller.handleMismatch({ required_revision: 2 }), 'manual'); + assert.equal(manualReloads, 1); +}); diff --git a/tests/js/terminal-manager-layout.test.js b/tests/js/terminal-manager-layout.test.js index bb73669a..ef9fe8c6 100644 --- a/tests/js/terminal-manager-layout.test.js +++ b/tests/js/terminal-manager-layout.test.js @@ -96,6 +96,8 @@ test('OSC 52 clipboard payloads are bounded, targeted, and decoded as UTF-8', () assert.equal(TerminalManager.decodeOsc52Clipboard('c;?'), null); assert.equal(TerminalManager.decodeOsc52Clipboard('c;%%%'), null); assert.equal(TerminalManager.decodeOsc52Clipboard('c;dG9vIGxhcmdl', 4), null); + const oversized = Buffer.alloc(128 * 1024 + 1, 0x61).toString('base64'); + assert.equal(TerminalManager.decodeOsc52Clipboard(`c;${oversized}`), null); }); test('OSC 52 handler requires a user action before writing the clipboard', async () => { diff --git a/tests/js/transfer-client.test.js b/tests/js/transfer-client.test.js index 47bad189..311bb859 100644 --- a/tests/js/transfer-client.test.js +++ b/tests/js/transfer-client.test.js @@ -41,6 +41,20 @@ function controlledSocket() { }; } +test('source identity changes remain actionable and retryable', () => { + const client = new BinaryTransferClient(controlledSocket().socket); + + assert.deepEqual(client.normalizeFailure({ + error_code: 'SOURCE_CHANGED', + error: 'The source changed during the transfer. Try again.', + retryable: true, + }), { + errorCode: 'SOURCE_CHANGED', + error: 'The source changed during the transfer. Try again.', + retryable: true, + }); +}); + test('uploads the File directly over HTTP after socket metadata preparation', async () => { const emitted = []; const socket = { on() {}, emit(event, payload, ack) { diff --git a/tests/test_admin_backup.py b/tests/test_admin_backup.py index 62afe5ba..02980694 100644 --- a/tests/test_admin_backup.py +++ b/tests/test_admin_backup.py @@ -332,12 +332,261 @@ def test_uploaded_backup_is_session_bound_and_requires_two_step_reauth( ).status_code in {302, 404} +@pytest.mark.parametrize( + 'failure', + ('durability_recheck', 'thread_start', 'thread_start_control_flow'), +) +def test_restore_start_failure_recovery_matches_launch_certainty( + app, + client, + isolated_operations, + tmp_path, + monkeypatch, + failure, +): + import app.backup_coordination as backup_coordination + import app.restore_service as restore_service + + username = { + 'thread_start_control_flow': 'restore_start_control', + }.get(failure, f'restore_start_{failure}') + user_id = _create_user(app, username, admin=True) + _login(client, username) + backup_session_id = 'restore-start-test-session-id-000000000001' + with client.session_transaction() as browser_session: + browser_session['_backup_admin_session_id'] = backup_session_id + + record = isolated_operations.create( + 'uploaded_backup', + user_id, + backup_session_id, + status='verified', + ) + record.archive_path.write_bytes(_valid_archive(tmp_path).read_bytes()) + token = isolated_operations.prepare_restore( + record.operation_id, + user_id, + backup_session_id, + ) + + # The endpoint's first durability gate succeeds. Exercise failures that + # occur only after begin_restore() has consumed the confirmation token. + monkeypatch.setattr( + backup_coordination, + 'require_durable_recovery_storage', + lambda: None, + ) + if failure == 'durability_recheck': + monkeypatch.setattr( + restore_service, + 'require_durable_recovery_storage', + lambda: (_ for _ in ()).throw( + RuntimeError('recovery storage changed') + ), + ) + + class UnexpectedThread: + def __init__(self, *_args, **_kwargs): + raise AssertionError('worker must not be created') + + monkeypatch.setattr(restore_service.threading, 'Thread', UnexpectedThread) + else: + monkeypatch.setattr( + restore_service, + 'require_durable_recovery_storage', + lambda: None, + ) + + class FailedThread: + def __init__(self, *_args, **_kwargs): + pass + + def start(self): + if failure == 'thread_start_control_flow': + raise KeyboardInterrupt + raise RuntimeError('thread capacity unavailable') + + monkeypatch.setattr(restore_service.threading, 'Thread', FailedThread) + + request_kwargs = { + 'json': { + 'confirmation_token': token, + 'confirmation_phrase': 'RESTORE', + 'confirm_destructive_restore': True, + }, + 'headers': _step_up(client, 'backup.restore', record.operation_id), + } + if failure == 'thread_start_control_flow': + with pytest.raises(KeyboardInterrupt): + client.post( + f'/admin/api/backups/{record.operation_id}/restore', + **request_kwargs, + ) + else: + response = client.post( + f'/admin/api/backups/{record.operation_id}/restore', + **request_kwargs, + ) + assert response.status_code == 503 + assert response.json == { + 'error': 'Restore could not be started', + 'code': 'RESTORE_START_FAILED', + } + if failure == 'thread_start_control_flow': + # A control-flow interruption may occur immediately after CPython has + # created the OS thread but before ident or the target is observable. + # Keep the consumed operation fail-closed instead of permitting two + # workers against the same archive. + assert record.status == 'restoring' + with pytest.raises(KeyError): + isolated_operations.prepare_restore( + record.operation_id, + user_id, + backup_session_id, + ) + isolated_operations.reset_unstarted_restore(record.operation_id) + return + assert record.status == 'verified' + assert record.error is None + assert record.metadata == {} + + # A failed launch must require a fresh confirmation but keep the verified + # archive available for an ordinary retry. + with pytest.raises(KeyError): + isolated_operations.begin_restore( + record.operation_id, + user_id, + backup_session_id, + token, + ) + replacement_token = isolated_operations.prepare_restore( + record.operation_id, + user_id, + backup_session_id, + ) + started = [] + monkeypatch.setattr( + restore_service, + 'start_restore', + lambda app, socketio, retry_record, username, source_ip: ( + started.append(retry_record) + ), + ) + retried = client.post( + f'/admin/api/backups/{record.operation_id}/restore', + json={ + 'confirmation_token': replacement_token, + 'confirmation_phrase': 'RESTORE', + 'confirm_destructive_restore': True, + }, + headers=_step_up(client, 'backup.restore', record.operation_id), + ) + + assert retried.status_code == 202 + assert started == [record] + assert record.status == 'restoring' + + +def test_restore_start_interruption_after_launch_never_enables_retry( + app, + client, + isolated_operations, + tmp_path, + monkeypatch, +): + import threading + + import app.backup_coordination as backup_coordination + import app.restore_service as restore_service + + username = 'restore_launched_interrupt' + user_id = _create_user(app, username, admin=True) + _login(client, username) + backup_session_id = 'restore-launched-test-session-id-0000000001' + with client.session_transaction() as browser_session: + browser_session['_backup_admin_session_id'] = backup_session_id + + record = isolated_operations.create( + 'uploaded_backup', + user_id, + backup_session_id, + status='verified', + ) + record.archive_path.write_bytes(_valid_archive(tmp_path).read_bytes()) + token = isolated_operations.prepare_restore( + record.operation_id, + user_id, + backup_session_id, + ) + monkeypatch.setattr( + backup_coordination, + 'require_durable_recovery_storage', + lambda: None, + ) + monkeypatch.setattr( + restore_service, + 'require_durable_recovery_storage', + lambda: None, + ) + + worker_entered = threading.Event() + allow_worker_exit = threading.Event() + monkeypatch.setattr( + restore_service, + '_perform_restore', + lambda *_args: ( + worker_entered.set(), + allow_worker_exit.wait(2), + ), + ) + real_thread = threading.Thread + started_threads = [] + + class StartedThenInterruptedThread(real_thread): + def start(self): + started_threads.append(self) + super().start() + raise KeyboardInterrupt + + monkeypatch.setattr( + restore_service.threading, + 'Thread', + StartedThenInterruptedThread, + ) + + with pytest.raises(KeyboardInterrupt): + client.post( + f'/admin/api/backups/{record.operation_id}/restore', + json={ + 'confirmation_token': token, + 'confirmation_phrase': 'RESTORE', + 'confirm_destructive_restore': True, + }, + headers=_step_up(client, 'backup.restore', record.operation_id), + ) + + assert worker_entered.wait(1) + assert record.status == 'restoring' + with pytest.raises(KeyError): + isolated_operations.prepare_restore( + record.operation_id, + user_id, + backup_session_id, + ) + + allow_worker_exit.set() + for worker in started_threads: + worker.join(2) + assert not worker.is_alive() + isolated_operations.reset_unstarted_restore(record.operation_id) + + def test_future_schema_is_verified_but_blocked_at_both_restore_gates( app, client, isolated_operations, tmp_path, monkeypatch ): user_id = _create_user(app, 'future_restore_admin', admin=True) current = _valid_archive(tmp_path) - future = _archive_with_data_schema(current, tmp_path / 'future.zip', 2) + future = _archive_with_data_schema(current, tmp_path / 'future.zip', 3) _login(client, 'future_restore_admin') uploaded = client.post( @@ -351,8 +600,8 @@ def test_future_schema_is_verified_but_blocked_at_both_restore_gates( assert verified.json['status'] == 'verified' assert verified.json['summary']['compatible'] is False - assert verified.json['summary']['data_schema_version'] == 2 - assert verified.json['summary']['current_data_schema_version'] == 1 + assert verified.json['summary']['data_schema_version'] == 3 + assert verified.json['summary']['current_data_schema_version'] == 2 assert verified.json['summary']['compatibility_reason'] == ( 'backup data schema is newer than this WebSSH version' ) diff --git a/tests/test_admin_cli.py b/tests/test_admin_cli.py index c383678a..28ea0789 100644 --- a/tests/test_admin_cli.py +++ b/tests/test_admin_cli.py @@ -10,6 +10,225 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1] +def _maintenance_cli( + data_dir, + *arguments, + environment_overrides=None, + app_target='start', + flask_module='flask', +): + environment = os.environ.copy() + environment.update({ + 'DATA_DIR': str(data_dir), + 'DEBUG': 'True', + 'SECRET_KEY': 'maintenance-cli-test-secret', + }) + if environment_overrides: + environment.update(environment_overrides) + return subprocess.run( + [ + sys.executable, + '-m', + flask_module, + '--app', + app_target, + *arguments, + ], + cwd=PROJECT_ROOT, + env=environment, + capture_output=True, + text=True, + check=False, + timeout=10, + ) + + +def _missing_ldap_runtime_environment(tmp_path): + return { + 'LDAP_ENABLED': 'true', + 'LDAP_URL': 'ldaps://directory.example.com:636', + 'LDAP_BASE_DN': 'dc=example,dc=com', + 'LDAP_BIND_DN': 'cn=service,dc=example,dc=com', + 'LDAP_BIND_PASSWORD_FILE': str(tmp_path / 'missing-ldap-password'), + 'LDAP_CA_FILE': str(tmp_path / 'missing-ldap-ca.pem'), + 'LDAP_USER_FILTER': '(&(objectClass=person)(uid={username}))', + 'LDAP_UNIQUE_ID_ATTRIBUTE': 'entryUUID', + } + + +def test_maintenance_help_uses_safe_factory_without_runtime_or_storage(tmp_path): + data_dir = tmp_path / 'connection-store' + + result = _maintenance_cli( + data_dir, + 'connection-store', + '--help', + app_target='app:create_app', + environment_overrides=_missing_ldap_runtime_environment(tmp_path), + ) + + assert result.returncode == 0, result.stderr + assert 'Usage:' in result.stdout + assert not data_dir.exists() + + +def test_maintenance_detection_supports_all_commands_and_flask_entrypoints(): + import app as app_module + + entrypoints = ( + ('flask', None), + ('python', 'flask.__main__'), + ('python', 'flask.cli'), + ) + for command in app_module._MAINTENANCE_COMMANDS: + for program_name, main_module_name in entrypoints: + assert app_module._is_maintenance_cli_invocation( + arguments=['--app', 'app:create_app', command, '--help'], + program_name=program_name, + main_module_name=main_module_name, + ) is True + + +def test_factory_target_connection_store_initializes_inside_command(tmp_path): + data_dir = tmp_path / 'connection-store-operation' + + result = _maintenance_cli( + data_dir, + 'connection-store', + 'list', + '--username', + 'missing-user', + '--kind', + 'profiles', + '--confirm-offline', + app_target='app:create_app', + environment_overrides=_missing_ldap_runtime_environment(tmp_path), + ) + + assert result.returncode != 0 + assert 'Account not found' in result.stderr + assert 'LDAP secret file is unavailable' not in result.stderr + assert (data_dir / 'app.db').is_file() + + +def test_factory_target_connection_store_locks_before_initialization( + tmp_path, + monkeypatch, +): + import config + from app.backup_coordination import operation_lock + + data_dir = tmp_path / 'locked-connection-store-operation' + operation_dir = tmp_path / 'operations' + monkeypatch.setattr(config, 'DATA_DIR', data_dir) + monkeypatch.setattr(config, 'BACKUP_TEMP_DIR', operation_dir) + monkeypatch.setattr(config, 'BACKUP_OPERATION_TIMEOUT', 1) + + with operation_lock(): + result = _maintenance_cli( + data_dir, + 'connection-store', + 'list', + '--username', + 'missing-user', + '--kind', + 'profiles', + '--confirm-offline', + app_target='app:create_app', + environment_overrides={ + **_missing_ldap_runtime_environment(tmp_path), + 'BACKUP_TEMP_DIR': str(operation_dir), + 'BACKUP_OPERATION_TIMEOUT': '1', + }, + ) + + assert result.returncode != 0 + assert 'another backup or restore operation is active' in result.stderr + assert not data_dir.exists() + + +def test_factor_bootstrap_missing_user_skips_ldap_runtime_files(tmp_path): + result = _maintenance_cli( + tmp_path / 'factor-bootstrap-ldap-data', + 'issue-factor-bootstrap', + '--username', + 'missing-user', + '--action', + 'passkey.enroll', + environment_overrides=_missing_ldap_runtime_environment(tmp_path), + ) + + assert result.returncode != 0 + assert 'Eligible account not found' in result.stderr + assert 'LDAP secret file is unavailable' not in result.stderr + + +def test_normal_app_factory_still_requires_ldap_runtime_files(tmp_path): + environment = os.environ.copy() + environment.update({ + 'DATA_DIR': str(tmp_path / 'normal-start-data'), + 'DEBUG': 'True', + 'SECRET_KEY': 'normal-start-test-secret', + **_missing_ldap_runtime_environment(tmp_path), + }) + + result = subprocess.run( + [ + sys.executable, + '-c', + ( + 'from app import create_app; ' + 'create_app(initialize_storage=False, start_runtime=False, ' + 'initialize_oidc=False)' + ), + ], + cwd=PROJECT_ROOT, + env=environment, + capture_output=True, + text=True, + check=False, + timeout=10, + ) + + assert result.returncode != 0 + assert 'LDAP secret file is unavailable' in result.stderr + + +def test_factory_target_nonmaintenance_command_keeps_ldap_fail_fast(tmp_path): + data_dir = tmp_path / 'nonmaintenance-data' + + result = _maintenance_cli( + data_dir, + 'routes', + app_target='app:create_app', + environment_overrides=_missing_ldap_runtime_environment(tmp_path), + ) + + assert result.returncode != 0 + assert 'LDAP secret file is unavailable' in result.stderr + assert not data_dir.exists() + + +def test_skipped_ldap_runtime_validation_is_not_marked_ready(monkeypatch): + import app as app_module + import config + + monkeypatch.setattr(config, 'LDAP_ENABLED', True) + monkeypatch.setattr( + app_module, + '_is_maintenance_cli_invocation', + lambda: True, + ) + + maintenance_app = app_module.create_app() + + assert maintenance_app.extensions['maintenance_cli_invocation'] is True + assert maintenance_app.extensions['security_feature_readiness']['ldap'] == ( + False, + 'LDAP runtime validation did not complete.', + ) + + def _admin(app, username): from app.models import User diff --git a/tests/test_audit_redaction.py b/tests/test_audit_redaction.py index 0a6762f0..76512c5d 100644 --- a/tests/test_audit_redaction.py +++ b/tests/test_audit_redaction.py @@ -94,3 +94,29 @@ def test_file_source_audit_uses_structured_smb_target_without_credentials( assert 'password' not in inspect.signature( log_file_source_operation ).parameters + + +def test_audit_sanitization_bounds_nested_attacker_controlled_values(): + from app.audit_logger import sanitize_audit_details + + sanitized = sanitize_audit_details({ + f'field-{index}': 'x' * 1000 + for index in range(100) + } | { + 'nested': {'one': {'two': {'three': {'four': 'unreachable'}}}}, + 'provider': {'access_token': 'must-not-survive'}, + }) + + assert len(sanitized) == 64 + assert all( + len(value) <= 512 + for value in sanitized.values() + if isinstance(value, str) + ) + + nested = sanitize_audit_details({ + 'nested': {'one': {'two': {'three': {'four': 'unreachable'}}}}, + 'provider': {'access_token': 'must-not-survive'}, + }) + assert nested['nested']['one']['two']['three']['four'] == '[TRUNCATED]' + assert nested['provider']['access_token'] == '[REDACTED]' diff --git a/tests/test_auth.py b/tests/test_auth.py index ed5aa1d5..a45f6b25 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -241,10 +241,12 @@ def test_fresh_homelab_redirects_to_one_time_registration( assert first.is_admin is True from app import socketio + from app.socket_protocol import SOCKET_WIRE_REVISION socket_client = socketio.test_client( app, flask_test_client=client, + auth={'wire_revision': SOCKET_WIRE_REVISION}, ) assert socket_client.is_connected() socket_client.disconnect() diff --git a/tests/test_backup_manager.py b/tests/test_backup_manager.py index cfcc42ff..65034caa 100644 --- a/tests/test_backup_manager.py +++ b/tests/test_backup_manager.py @@ -299,12 +299,12 @@ def test_cli_verify_reports_legacy_restore_compatibility(tmp_path): def test_cli_verify_reports_future_schema_without_accepting_restore(tmp_path): - archive = tmp_path / 'future-v2.zip' + archive = tmp_path / 'future-v3.zip' _write_manifest_archive( archive, {'app.db': _webssh_database_bytes(tmp_path)}, format_version=2, - data_schema_version=2, + data_schema_version=3, ) result = _maintenance_cli( @@ -313,7 +313,7 @@ def test_cli_verify_reports_future_schema_without_accepting_restore(tmp_path): assert result.returncode == 0, result.stderr assert 'format v2' in result.stdout - assert 'data schema 2' in result.stdout + assert 'data schema 3' in result.stdout assert 'restore incompatible' in result.stdout @@ -326,11 +326,28 @@ def test_new_backup_records_v2_compatibility_metadata(tmp_path): manifest = create_backup(data_dir, archive) assert manifest.format_version == 2 - assert manifest.data_schema_version == 1 + assert manifest.data_schema_version == 2 assert manifest.producer == 'webssh' assert manifest.created_at.endswith('Z') +def test_previous_backup_data_schema_remains_restore_compatible(): + manifest = backup_manager.BackupManifest( + format_version=2, + files=(), + data_schema_version=1, + created_at='2026-08-03T12:00:00Z', + producer='webssh', + ) + + compatibility = backup_manager.evaluate_backup_compatibility(manifest) + + assert compatibility.compatible is True + assert compatibility.legacy is False + assert compatibility.current_data_schema_version == 2 + assert compatibility.reason == 'backup data schema can be migrated' + + def test_v1_backup_is_legacy_and_migratable(tmp_path): archive = tmp_path / 'legacy-v1.zip' _write_manifest_archive( @@ -349,12 +366,12 @@ def test_v1_backup_is_legacy_and_migratable(tmp_path): def test_future_data_schema_verifies_but_is_not_restore_compatible(tmp_path): - archive = tmp_path / 'future-v2.zip' + archive = tmp_path / 'future-v3.zip' _write_manifest_archive( archive, {'app.db': _webssh_database_bytes(tmp_path)}, format_version=2, - data_schema_version=2, + data_schema_version=3, ) manifest = verify_backup(archive) @@ -365,12 +382,12 @@ def test_future_data_schema_verifies_but_is_not_restore_compatible(tmp_path): def test_future_data_schema_is_rejected_before_restore_mutates_data(tmp_path): - archive = tmp_path / 'future-v2.zip' + archive = tmp_path / 'future-v3.zip' _write_manifest_archive( archive, {'app.db': _webssh_database_bytes(tmp_path)}, format_version=2, - data_schema_version=2, + data_schema_version=3, ) restore_dir = tmp_path / 'restore' restore_dir.mkdir() @@ -550,7 +567,7 @@ def test_create_verify_and_restore_round_trip(tmp_path): assert _snapshot(restored_dir) == expected_files -def test_backup_excludes_logs_and_transfer_temporary_files(tmp_path): +def test_backup_excludes_runtime_only_files(tmp_path): data_dir = tmp_path / 'data' data_dir.mkdir() expected_files = _write_representative_data(data_dir) @@ -558,6 +575,9 @@ def test_backup_excludes_logs_and_transfer_temporary_files(tmp_path): (data_dir / 'logs' / 'webssh.log').write_bytes(b'active log') (data_dir / 'tmp').mkdir() (data_dir / 'tmp' / 'partial-upload').write_bytes(b'partial') + fences = data_dir / '.ldap-revocation-fences' + fences.mkdir() + (fences / '42.pending').write_bytes(b'pending\n') archive = tmp_path / 'backup.zip' manifest = create_backup(data_dir, archive) @@ -570,6 +590,30 @@ def test_backup_excludes_logs_and_transfer_temporary_files(tmp_path): assert _snapshot(restored_dir) == expected_files +def test_restore_discards_current_and_archived_ldap_revocation_fences( + tmp_path, +): + archive = tmp_path / 'backup-with-runtime-fence.zip' + _write_manifest_archive( + archive, + { + 'app.db': _webssh_database_bytes(tmp_path), + '.ldap-revocation-fences/42.pending': b'pending\n', + }, + format_version=2, + data_schema_version=2, + ) + restore_dir = tmp_path / 'restore' + existing_fence = restore_dir / '.ldap-revocation-fences/99.pending' + existing_fence.parent.mkdir(parents=True) + existing_fence.write_bytes(b'pending\n') + + restore_backup(archive, restore_dir) + + assert not existing_fence.exists() + assert not (restore_dir / '.ldap-revocation-fences/42.pending').exists() + + def test_corrupt_member_fails_before_restore_writes_anything(tmp_path): source_dir = tmp_path / 'source' source_dir.mkdir() @@ -848,12 +892,16 @@ def test_restore_removes_stale_persistent_files_but_keeps_runtime_files( temporary = restore_dir / 'tmp/active-transfer' temporary.parent.mkdir() temporary.write_bytes(b'keep active temp state') + fence = restore_dir / '.ldap-revocation-fences/99.pending' + fence.parent.mkdir() + fence.write_bytes(b'pending\n') restore_backup(archive, restore_dir) assert not stale.exists() assert log.read_bytes() == b'keep current runtime log' assert temporary.read_bytes() == b'keep active temp state' + assert not fence.exists() persistent_snapshot = { path: payload for path, payload in _snapshot(restore_dir).items() diff --git a/tests/test_backup_recovery_storage.py b/tests/test_backup_recovery_storage.py new file mode 100644 index 00000000..0b7aeccd --- /dev/null +++ b/tests/test_backup_recovery_storage.py @@ -0,0 +1,63 @@ +"""Durable recovery storage gates for online restore.""" + +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +def test_web_restore_rejects_missing_durability_acknowledgement( + monkeypatch, +): + import config + from app.backup_coordination import require_durable_recovery_storage + + monkeypatch.setattr(config, 'BACKUP_RECOVERY_DURABLE', False) + + with pytest.raises(RuntimeError, match='durable recovery storage'): + require_durable_recovery_storage() + + +def test_web_restore_recovery_root_must_be_absolute_and_outside_data( + tmp_path, + monkeypatch, +): + import config + from app.backup_coordination import require_durable_recovery_storage + + monkeypatch.setattr(config, 'BACKUP_RECOVERY_DURABLE', True) + monkeypatch.setattr(config, 'BACKUP_TEMP_DIR', tmp_path / 'data' / 'recovery') + monkeypatch.setattr(config, 'DATA_DIR', tmp_path / 'data') + + with pytest.raises(RuntimeError, match='outside DATA_DIR'): + require_durable_recovery_storage() + + monkeypatch.setattr(config, 'BACKUP_TEMP_DIR', Path('relative')) + with pytest.raises(RuntimeError, match='must be absolute'): + require_durable_recovery_storage() + + +def test_start_restore_does_not_create_worker_without_durable_storage( + monkeypatch, +): + import config + from app import restore_service + + monkeypatch.setattr(config, 'BACKUP_RECOVERY_DURABLE', False) + started = [] + + class UnexpectedThread: + def __init__(self, *args, **kwargs): + started.append((args, kwargs)) + + monkeypatch.setattr(restore_service.threading, 'Thread', UnexpectedThread) + + with pytest.raises(RuntimeError, match='durable recovery storage'): + restore_service.start_restore( + SimpleNamespace(), + SimpleNamespace(), + SimpleNamespace(), + 'admin', + '127.0.0.1', + ) + assert started == [] diff --git a/tests/test_command_set_manager.py b/tests/test_command_set_manager.py index 1345db8b..b428c6bd 100644 --- a/tests/test_command_set_manager.py +++ b/tests/test_command_set_manager.py @@ -1254,3 +1254,206 @@ def test_delete_unreferenced_command_set(app, monkeypatch): assert (success, error, profiles) == (True, None, []) assert load_error is None assert loaded == [] + + +def test_oversized_profiles_quarantine_all_live_command_deletes( + app, + monkeypatch, +): + import json + import config + from app import command_manager, command_set_manager, profile_manager + from app.storage_migrations import CURRENT_STORAGE_VERSIONS + + user_id = create_user(app, 'oversized-profile-command-recovery') + with app.app_context(): + unused_command = command_manager.add_user_command( + user_id, + 'Disposable command', + 'true', + '', + 'Safe to delete', + ['all'], + 'custom', + ) + referenced_command = command_manager.add_user_command( + user_id, + 'Referenced command', + 'true', + '', + 'Must remain', + ['all'], + 'custom', + ) + unused_set, error = command_set_manager.upsert_command_set(user_id, { + 'name': 'Disposable set', + 'steps': [{'type': 'inline', 'command': 'true'}], + }) + assert error is None + referenced_set, error = command_set_manager.upsert_command_set( + user_id, + { + 'name': 'Referenced set', + 'steps': [{'type': 'inline', 'command': 'true'}], + }, + ) + assert error is None + path = profile_manager.get_user_profiles_file(user_id) + path.write_text(json.dumps({ + 'schema_version': CURRENT_STORAGE_VERSIONS['profiles'], + 'profiles': [{ + 'id': 'legacy-large', + 'name': 'Protected profile', + 'command_id': referenced_command['id'], + 'command_set_id': referenced_set['id'], + 'future': 'x' * 1024, + }], + }), encoding='utf-8') + original_profiles = path.read_bytes() + monkeypatch.setattr(config, 'CONNECTION_STORE_MAX_BYTES', 256) + monkeypatch.setattr( + config, + 'CONNECTION_STORE_RECOVERY_MAX_BYTES', + 4096, + ) + + normal_error = ( + 'Connection storage quota exceeded: stored data exceeds its byte limit' + ) + assert command_manager.delete_user_command( + user_id, unused_command['id'] + ) == (False, normal_error, []) + assert command_set_manager.delete_command_set( + user_id, unused_set['id'] + ) == (False, normal_error, []) + assert command_manager.delete_user_command( + user_id, referenced_command['id'] + ) == (False, normal_error, []) + assert command_set_manager.delete_command_set( + user_id, referenced_set['id'] + ) == (False, normal_error, []) + assert path.read_bytes() == original_profiles + assert any( + item['id'] == unused_command['id'] + for item in command_manager.load_user_commands(user_id) + ) + loaded_sets, load_error = command_set_manager.load_command_sets(user_id) + assert load_error is None + assert {item['id'] for item in loaded_sets} == { + unused_set['id'], + referenced_set['id'], + } + + +def test_command_reference_details_are_bounded_and_sanitized( + app, + monkeypatch, +): + from app import command_manager, command_set_manager + + user_id = create_user(app, 'bounded-command-reference-details') + detail_limit = command_set_manager._REFERENCE_USAGE_DETAIL_LIMIT + profiles = [ + { + 'id': f'profile-{index}\n' + ('\u00e9' * 256), + 'name': f'Profile {index}\r' + ('\u754c' * 256), + 'command_id': 'guarded-command', + 'command_set_id': 'guarded-set', + } + for index in range(detail_limit + 7) + ] + monkeypatch.setattr( + command_set_manager, + '_load_profile_references', + lambda _user_id: (profiles, None), + ) + + with app.app_context(): + command_result = command_manager.delete_user_command( + user_id, + 'guarded-command', + ) + set_result = command_set_manager.delete_command_set( + user_id, + 'guarded-set', + ) + public_usages, public_error = command_set_manager.get_command_usage( + user_id, + 'guarded-command', + ) + + expected_count = len(profiles) + assert command_result[0] is False + assert command_result[1] == ( + f'Command is used by {expected_count} profiles ' + f'(showing first {detail_limit})' + ) + assert len(command_result[2]) == detail_limit + assert command_result[2] == public_usages + assert public_error is None + assert all('\n' not in item['id'] for item in public_usages) + assert all('\r' not in item['name'] for item in public_usages) + assert all( + len(item['id'].encode('utf-8')) <= 128 for item in public_usages + ) + assert all( + len(item['name'].encode('utf-8')) <= 128 for item in public_usages + ) + + assert set_result[0] is False + assert set_result[1] == ( + f'Command set is used by {expected_count} profiles ' + f'(showing first {detail_limit})' + ) + assert len(set_result[2]) == detail_limit + assert all('\r' not in name for name in set_result[2]) + assert all(len(name.encode('utf-8')) <= 128 for name in set_result[2]) + + +def test_command_reference_noun_uses_all_references_beyond_detail_cap( + app, + monkeypatch, +): + from app import command_manager, command_set_manager + + user_id = create_user(app, 'complete-command-reference-count') + detail_limit = command_set_manager._REFERENCE_USAGE_DETAIL_LIMIT + command_sets = [ + { + 'id': f'set-{index}', + 'name': f'Set {index}', + 'steps': [{ + 'type': 'library', + 'command_id': 'guarded-command', + }], + } + for index in range(detail_limit + 1) + ] + monkeypatch.setattr( + command_set_manager, + '_load_command_sets_with_lock_held', + lambda _user_id: (command_sets, None), + ) + monkeypatch.setattr( + command_set_manager, + '_load_profile_references', + lambda _user_id: ([{ + 'id': 'profile-reference', + 'name': 'Profile reference', + 'command_id': 'guarded-command', + }], None), + ) + + with app.app_context(): + success, error, usages = command_manager.delete_user_command( + user_id, + 'guarded-command', + ) + + assert success is False + assert error == ( + f'Command is used by {detail_limit + 2} references ' + f'(showing first {detail_limit})' + ) + assert len(usages) == detail_limit + assert {usage['type'] for usage in usages} == {'command_set'} diff --git a/tests/test_command_set_socket_events.py b/tests/test_command_set_socket_events.py index 36618ac3..984fb274 100644 --- a/tests/test_command_set_socket_events.py +++ b/tests/test_command_set_socket_events.py @@ -135,6 +135,52 @@ def test_profile_save_and_update_return_ack_without_connecting(app, monkeypatch) assert updated['profile']['host'] == 'new.example.com' +def test_tailscale_profile_save_derives_authorization_without_persisting_it( + app, + monkeypatch, +): + from app import profile_manager + import app.socket_events as socket_events + + user_id, sid = create_socket_user(app, 'tailscale_profile_save') + monkeypatch.setattr( + socket_events, + 'validate_tailscale_ssh_access', + lambda *_args, **_kwargs: None, + ) + monkeypatch.setattr( + socket_events, + 'profile_is_authorized_for_launch', + lambda _user, _profile: False, + ) + client_payload = { + 'name': 'Tailnet server', + 'host': 'tiny-server', + 'port': 22, + 'username': 'root', + 'auth_type': 'tailscale', + 'tailscale_authorized': True, + } + + created, _emitted = call_socket_handler( + app, + monkeypatch, + socket_events.handle_save_profile, + sid, + client_payload, + ) + + assert created['success'] is True + assert created['profile']['tailscale_authorized'] is False + assert client_payload['tailscale_authorized'] is True + with app.app_context(): + stored = profile_manager.get_profile( + user_id, + created['profile']['id'], + ) + assert 'tailscale_authorized' not in stored + + def test_profile_update_rejects_foreign_or_missing_id(app, monkeypatch): import app.socket_events as socket_events @@ -187,7 +233,47 @@ def test_profile_organization_socket_updates_current_users_profile( assert result['success'] is True assert result['profile']['favorite'] is True assert ('profile_organization_updated', result) in emitted - assert any(event == 'profiles_list' for event, _payload in emitted) + assert all(event != 'profiles_list' for event, _payload in emitted) + + +def test_tailscale_profile_organization_refreshes_transient_authorization( + app, + monkeypatch, +): + from app import profile_manager + import app.socket_events as socket_events + + user_id, sid = create_socket_user(app, 'tailscale_profile_organization') + with app.app_context(): + profile, error = profile_manager.add_profile( + user_id, + 'Tailnet server', + 'tiny-server', + 22, + 'root', + 'tailscale', + ) + assert error is None + monkeypatch.setattr( + socket_events, + 'profile_is_authorized_for_launch', + lambda _user, _profile: False, + ) + + result, emitted = call_socket_handler( + app, + monkeypatch, + socket_events.handle_update_profile_organization, + sid, + {'profile_id': profile['id'], 'favorite': True}, + ) + + assert result['success'] is True + assert result['profile']['tailscale_authorized'] is False + assert ('profile_organization_updated', result) in emitted + with app.app_context(): + stored = profile_manager.get_profile(user_id, profile['id']) + assert 'tailscale_authorized' not in stored def test_profile_organization_socket_rejects_missing_profile_id( @@ -271,7 +357,6 @@ def test_move_profile_socket_requests_confirmation_without_writing_or_broadcast( assert result == { 'success': False, - 'profiles': profiles, 'requires_confirmation': True, 'profile_id': 'critical', 'profile_name': 'Critical DB', @@ -282,7 +367,7 @@ def test_move_profile_socket_requests_confirmation_without_writing_or_broadcast( assert profile_manager.load_profiles(user_id) == profiles -def test_move_profile_socket_returns_authoritative_profiles_after_confirmed_write( +def test_move_profile_socket_returns_bounded_organization_after_confirmed_write( app, monkeypatch, ): from app import profile_manager @@ -312,10 +397,11 @@ def test_move_profile_socket_returns_authoritative_profiles_after_confirmed_writ assert result['success'] is True assert result['requires_confirmation'] is False - ordered = sorted(result['profiles'], key=lambda item: item['sort_order']) + assert 'profiles' not in result + ordered = sorted(result['organization'], key=lambda item: item['sort_order']) assert [item['id'] for item in ordered] == ['worker', 'critical'] assert ('profile_organization_updated', result) in emitted - assert any(event == 'profiles_list' for event, _payload in emitted) + assert all(event != 'profiles_list' for event, _payload in emitted) def test_move_profile_socket_returns_authoritative_state_when_source_is_stale( @@ -347,12 +433,59 @@ def test_move_profile_socket_returns_authoritative_state_when_source_is_stale( assert result == { 'success': False, 'error': 'Profile group changed; retry move', - 'profiles': profiles, 'requires_confirmation': False, + 'organization': [{ + 'id': 'api', + 'group': 'Production', + 'sort_order': 0, + }], } assert emitted == [] +def test_saved_connection_mutations_have_a_shared_per_user_rate_limit( + app, + monkeypatch, +): + import config + import app.socket_events as socket_events + + user_id, sid = create_socket_user(app, 'profile_mutation_limit') + calls = [] + monkeypatch.setattr(config, 'RATELIMIT_ENABLED', True) + + def limited(candidate_user_id, operation, rate): + calls.append((candidate_user_id, operation, rate)) + return True + + monkeypatch.setattr(socket_events, 'check_socket_rate_limit', limited) + result, emitted = call_socket_handler( + app, + monkeypatch, + socket_events.handle_save_profile, + sid, + { + 'name': 'Production', + 'host': 'example.com', + 'port': 22, + 'username': 'deploy', + 'auth_type': 'password', + }, + ) + + assert result == { + 'success': False, + 'error': 'Too many saved-connection changes. Please wait before trying again.', + 'code': 'rate_limited', + } + assert calls == [( + user_id, + 'connection_mutation', + config.RATELIMIT_CONNECTION_MUTATION, + )] + assert emitted == [('error', result)] + + def test_update_user_command_rejects_unknown_id_without_writing(app, monkeypatch): from app import command_manager @@ -877,7 +1010,7 @@ def test_revoked_target_key_stops_before_rate_limit_dns_and_network( def test_revoked_live_jump_key_stops_before_rate_limit_dns_and_network( - app, monkeypatch + app, monkeypatch, rsa_private_key_pem ): from flask import request from app import jump_host_manager, key_manager, ssh_manager @@ -885,6 +1018,10 @@ def test_revoked_live_jump_key_stops_before_rate_limit_dns_and_network( user_id, sid = create_socket_user(app, 'revoked_live_jump_key') with app.app_context(): + key, key_error = key_manager.save_key( + user_id, 'Revoked jump key', rsa_private_key_pem + ) + assert key_error is None jump_host, error = jump_host_manager.add_jump_host( user_id, 'Key Bastion', @@ -892,15 +1029,16 @@ def test_revoked_live_jump_key_stops_before_rate_limit_dns_and_network( 22, 'jump-user', 'key', - key_id='revoked-jump-key', + key_id=key['id'], ) assert error is None + assert key_manager.delete_key(user_id, key['id']) is True monkeypatch.setattr( key_manager, 'read_key_content', lambda value, key_id: ( (None, 'Key not found') - if (value, key_id) == (user_id, 'revoked-jump-key') + if (value, key_id) == (user_id, key['id']) else (_ for _ in ()).throw(AssertionError('unexpected key')) ), ) @@ -1376,6 +1514,137 @@ def test_jump_host_delete_returns_in_use_and_not_found_codes( assert missing_events == [('error', missing)] +def test_jump_host_delete_socket_payload_caps_reference_details( + app, + monkeypatch, +): + from app import jump_host_manager, profile_manager + import app.socket_events as socket_events + + user_id, sid = create_socket_user(app, 'jump_delete_detail_cap') + detail_limit = jump_host_manager._JUMP_HOST_USAGE_DETAIL_LIMIT + with app.app_context(): + jump_host, error = jump_host_manager.add_jump_host( + user_id, + 'Bastion', + 'bastion.example', + 22, + 'jump-user', + 'password', + ) + assert error is None + profiles = [ + { + 'id': f'profile-{index}', + 'name': f'Profile {index}', + 'jump_host_id': jump_host['id'], + } + for index in range(detail_limit + 7) + ] + assert profile_manager.save_profiles(user_id, profiles) + + result, emitted = call_socket_handler( + app, + monkeypatch, + socket_events.handle_delete_jump_host, + sid, + {'jump_host_id': jump_host['id']}, + ) + + assert result == { + 'success': False, + 'error': ( + f'Jump host is used by {len(profiles)} profiles ' + f'(showing first {detail_limit})' + ), + 'code': 'in_use', + 'usages': [f'Profile {index}' for index in range(detail_limit)], + } + assert emitted == [('error', result)] + + +def test_command_delete_socket_payloads_cap_and_sanitize_reference_details( + app, + monkeypatch, +): + from app import command_manager, command_set_manager, profile_manager + import app.socket_events as socket_events + + user_id, sid = create_socket_user(app, 'command_delete_detail_cap') + detail_limit = command_set_manager._REFERENCE_USAGE_DETAIL_LIMIT + with app.app_context(): + command = command_manager.add_user_command( + user_id, + 'Guarded command', + 'true', + '', + 'Guarded command', + ['all'], + 'custom', + ) + command_set, error = command_set_manager.upsert_command_set( + user_id, + { + 'name': 'Guarded set', + 'steps': [{'type': 'inline', 'command': 'true'}], + }, + ) + assert error is None + profiles = [ + { + 'id': f'profile-{index}\n' + ('\u00e9' * 256), + 'name': f'Profile {index}\r' + ('\u754c' * 256), + 'command_id': command['id'], + 'command_set_id': command_set['id'], + } + for index in range(detail_limit + 7) + ] + assert profile_manager.save_profiles(user_id, profiles) + + command_result, command_events = call_socket_handler( + app, + monkeypatch, + socket_events.handle_delete_command, + sid, + {'command_id': command['id']}, + ) + set_result, set_events = call_socket_handler( + app, + monkeypatch, + socket_events.handle_delete_command_set, + sid, + {'command_set_id': command_set['id']}, + ) + + expected_count = len(profiles) + assert command_result['error'] == ( + f'Command is used by {expected_count} profiles ' + f'(showing first {detail_limit})' + ) + assert command_result['code'] == 'in_use' + assert len(command_result['usages']) == detail_limit + assert all( + len(usage['id'].encode('utf-8')) <= 128 + and len(usage['name'].encode('utf-8')) <= 128 + and '\n' not in usage['id'] + and '\r' not in usage['name'] + for usage in command_result['usages'] + ) + assert command_events == [('error', command_result)] + + assert set_result['error'] == ( + f'Command set is used by {expected_count} profiles ' + f'(showing first {detail_limit})' + ) + assert set_result['code'] == 'in_use' + assert len(set_result['usages']) == detail_limit + assert all( + len(name.encode('utf-8')) <= 128 and '\r' not in name + for name in set_result['usages'] + ) + assert set_events == [('error', set_result)] + + def test_missing_command_update_and_profile_delete_emit_structured_errors( app, monkeypatch ): @@ -1416,6 +1685,73 @@ def test_missing_command_update_and_profile_delete_emit_structured_errors( assert profile_events == [('error', profile_result)] +def test_live_socket_deletes_cannot_mutate_quarantined_connection_store( + app, + monkeypatch, +): + import config + from app import jump_host_manager, profile_manager + from app.storage_migrations import CURRENT_STORAGE_VERSIONS + import app.socket_events as socket_events + + user_id, sid = create_socket_user(app, 'quarantined_socket_delete') + with app.app_context(): + jump_host, error = jump_host_manager.add_jump_host( + user_id, + 'Bastion', + 'bastion.example', + 22, + 'deploy', + 'password', + ) + assert error is None + profile_path = profile_manager.get_user_profiles_file(user_id) + profile_path.write_text(json.dumps({ + 'schema_version': CURRENT_STORAGE_VERSIONS['profiles'], + 'profiles': [{ + 'id': 'legacy-profile', + 'name': 'Legacy profile', + 'jump_host_id': jump_host['id'], + 'future': 'x' * 2048, + }], + }), encoding='utf-8') + original_profiles = profile_path.read_bytes() + jump_path = jump_host_manager._get_file(user_id) + original_jump_hosts = jump_path.read_bytes() + monkeypatch.setattr(config, 'CONNECTION_STORE_MAX_BYTES', 256) + + expected_error = ( + 'Connection storage quota exceeded: stored data exceeds its byte limit' + ) + profile_result, profile_events = call_socket_handler( + app, + monkeypatch, + socket_events.handle_delete_profile, + sid, + {'profile_id': 'legacy-profile'}, + ) + jump_result, jump_events = call_socket_handler( + app, + monkeypatch, + socket_events.handle_delete_jump_host, + sid, + {'jump_host_id': jump_host['id']}, + ) + + expected_result = { + 'success': False, + 'error': expected_error, + 'code': 'quota_exceeded', + } + assert profile_result == expected_result + assert profile_events == [('error', expected_result)] + assert jump_result == expected_result + assert jump_events == [('error', expected_result)] + with app.app_context(): + assert profile_path.read_bytes() == original_profiles + assert jump_path.read_bytes() == original_jump_hosts + + def test_socket_command_set_errors_are_structured(app, monkeypatch): from app.models import User, db import app.socket_events as socket_events @@ -1596,6 +1932,55 @@ def capture_upsert(user_id, payload): assert stored == converted['profile'] +@pytest.mark.parametrize('authorized', (True, False)) +def test_convert_tailscale_legacy_profile_refreshes_transient_authorization( + app, + monkeypatch, + authorized, +): + from app import profile_manager + import app.socket_events as socket_events + + suffix = 'allowed' if authorized else 'denied' + user_id, sid = create_socket_user(app, f'tailscale_convert_{suffix}') + with app.app_context(): + profile, error = profile_manager.add_profile( + user_id, + 'Legacy tailnet', + 'tiny-server', + 22, + 'root', + 'tailscale', + startup_commands='echo legacy', + ) + assert error is None + monkeypatch.setattr( + socket_events, + 'profile_is_authorized_for_launch', + lambda _user, _profile: authorized, + ) + + converted, _emitted = call_socket_handler( + app, + monkeypatch, + socket_events.handle_convert_legacy_command_set, + sid, + { + 'profile_id': profile['id'], + 'name': f'Tailnet bootstrap {suffix}', + }, + ) + + assert converted['success'] is True + assert converted['profile']['tailscale_authorized'] is authorized + with app.app_context(): + stored = profile_manager.get_profile(user_id, profile['id']) + assert 'tailscale_authorized' not in stored + response_without_authorization = dict(converted['profile']) + response_without_authorization.pop('tailscale_authorized') + assert response_without_authorization == stored + + def test_convert_rejects_profile_without_legacy_commands(app, monkeypatch): from app import profile_manager import app.socket_events as socket_events diff --git a/tests/test_connection_store_cli.py b/tests/test_connection_store_cli.py new file mode 100644 index 00000000..f1e22c88 --- /dev/null +++ b/tests/test_connection_store_cli.py @@ -0,0 +1,466 @@ +"""Offline recovery CLI contracts for quarantined legacy connection stores.""" + +import json +import re + +import pytest + + +def _run_cli_child( + flask_app, + arguments, + results, + *, + start=None, + ready=None, + initialization_started=None, +): + try: + if initialization_started is not None: + import app as app_package + + original_initialize = app_package._initialize_persistent_storage + + def observe_initialize(application): + initialization_started.set() + return original_initialize(application) + + app_package._initialize_persistent_storage = observe_initialize + if ready is not None: + ready.set() + if start is not None and not start.wait(timeout=5): + raise RuntimeError('child CLI start was not released') + result = flask_app.test_cli_runner().invoke(args=arguments) + results.put(('result', result.exit_code, result.output)) + except BaseException as exc: + results.put(('exception', 1, repr(exc))) + + +def _run_paused_profile_delete_child( + flask_app, + arguments, + loaded, + release, + results, +): + from app import profile_manager + + original_load = profile_manager._load_profiles_for_recovery_delete + + def load_then_pause(*args, **kwargs): + value = original_load(*args, **kwargs) + loaded.set() + if not release.wait(timeout=5): + raise RuntimeError('paused recovery delete was not released') + return value + + profile_manager._load_profiles_for_recovery_delete = load_then_pause + _run_cli_child(flask_app, arguments, results) + + +def _run_contended_profile_delete_child( + flask_app, + arguments, + blocked, + results, +): + from app import backup_coordination + + original_try_lock = backup_coordination._try_lock + + def observe_try_lock(descriptor): + acquired = original_try_lock(descriptor) + if not acquired: + blocked.set() + return acquired + + backup_coordination._try_lock = observe_try_lock + _run_cli_child(flask_app, arguments, results) + + +def _create_user(app, username): + from app.models import User, db + + with app.app_context(): + user = User(username=username, password_hash='not-used') + db.session.add(user) + db.session.commit() + return user.id + + +def test_profile_recovery_cli_lists_no_secrets_and_deletes_exact_record( + app, + monkeypatch, +): + import config + from app import profile_manager + from app.connection_storage_policy import ConnectionStorageLimitError + from app.storage_migrations import CURRENT_STORAGE_VERSIONS + + username = 'profile-recovery-cli' + user_id = _create_user(app, username) + secret = 'never-print-this-startup-command' + with app.app_context(): + path = profile_manager.get_user_profiles_file(user_id) + path.write_text(json.dumps({ + 'schema_version': CURRENT_STORAGE_VERSIONS['profiles'], + 'profiles': [{ + 'id': 'legacy-profile-id', + 'name': 'Legacy profile', + 'host': 'legacy.example', + 'startup_commands': secret, + 'future': 'x' * 2048, + }], + }), encoding='utf-8') + monkeypatch.setattr(config, 'CONNECTION_STORE_MAX_BYTES', 256) + with pytest.raises(ConnectionStorageLimitError): + profile_manager.load_profiles(user_id) + + runner = app.test_cli_runner() + refused = runner.invoke(args=[ + 'connection-store', 'list', '--username', username, + '--kind', 'profiles', + ]) + assert refused.exit_code != 0 + assert '--confirm-offline' in refused.output + + listed = runner.invoke(args=[ + 'connection-store', 'list', '--username', username, + '--kind', 'profiles', '--confirm-offline', + ]) + assert listed.exit_code == 0, listed.output + assert secret not in listed.output + listing = json.loads(listed.output) + assert listing == { + 'count': 1, + 'kind': 'profiles', + 'records': [{ + 'host': 'legacy.example', + 'id': 'legacy-profile-id', + 'name': 'Legacy profile', + 'selector': listing['records'][0]['selector'], + }], + } + selector = listing['records'][0]['selector'] + assert re.fullmatch(r'r1:0:[0-9a-f]{64}', selector) + + deleted = runner.invoke(args=[ + 'connection-store', 'delete', '--username', username, + '--kind', 'profiles', '--selector', selector, + '--confirm-offline', + ]) + assert deleted.exit_code == 0, deleted.output + with app.app_context(): + assert profile_manager.load_profiles(user_id) == [] + + +def test_jump_host_recovery_cli_preserves_reference_protection( + app, + monkeypatch, +): + import config + from app import jump_host_manager, profile_manager + from app.storage_migrations import CURRENT_STORAGE_VERSIONS + + username = 'jump-recovery-cli' + user_id = _create_user(app, username) + with app.app_context(): + jump_path = jump_host_manager._get_file(user_id) + jump_path.write_text(json.dumps({ + 'schema_version': CURRENT_STORAGE_VERSIONS['jump_hosts'], + 'jump_hosts': [{ + 'id': 'legacy-jump-id', + 'name': 'Legacy jump', + 'host': 'jump.example', + 'port': 22, + 'username': 'deploy', + 'auth_type': 'password', + 'future': 'x' * 2048, + }], + }), encoding='utf-8') + assert profile_manager.save_profiles(user_id, [{ + 'id': 'profile-id', + 'name': 'Production', + 'host': 'target.example', + 'port': 22, + 'username': 'deploy', + 'auth_type': 'password', + 'jump_host_id': 'legacy-jump-id', + }]) + monkeypatch.setattr(config, 'CONNECTION_STORE_MAX_BYTES', 256) + + runner = app.test_cli_runner() + listed = runner.invoke(args=[ + 'connection-store', 'list', '--username', username, + '--kind', 'jump-hosts', '--confirm-offline', + ]) + assert listed.exit_code == 0, listed.output + records = json.loads(listed.output)['records'] + assert records == [{ + 'host': 'jump.example', + 'id': 'legacy-jump-id', + 'name': 'Legacy jump', + 'selector': records[0]['selector'], + }] + + refused = runner.invoke(args=[ + 'connection-store', 'delete', '--username', username, + '--kind', 'jump-hosts', '--selector', records[0]['selector'], + '--confirm-offline', + ]) + assert refused.exit_code != 0 + assert 'used by 1 profile' in refused.output + + +def test_profile_recovery_cli_selectors_are_unique_and_delete_one_record( + app, + monkeypatch, +): + import config + from app import profile_manager + from app.storage_migrations import CURRENT_STORAGE_VERSIONS + + username = 'profile-recovery-selector-cli' + user_id = _create_user(app, username) + shared_prefix = 'x' * 128 + profiles = [ + { + 'id': f'{shared_prefix}-first', + 'name': 'First collision', + }, + { + 'id': f'{shared_prefix}-second', + 'name': 'Second collision', + }, + { + 'id': 'duplicate-id', + 'name': 'First duplicate', + }, + { + 'id': 'duplicate-id', + 'name': 'Second duplicate', + }, + ] + with app.app_context(): + path = profile_manager.get_user_profiles_file(user_id) + path.write_text(json.dumps({ + 'schema_version': CURRENT_STORAGE_VERSIONS['profiles'], + 'profiles': profiles, + }), encoding='utf-8') + monkeypatch.setattr(config, 'CONNECTION_STORE_MAX_BYTES', 128) + + runner = app.test_cli_runner() + listed = runner.invoke(args=[ + 'connection-store', 'list', '--username', username, + '--kind', 'profiles', '--confirm-offline', + ]) + assert listed.exit_code == 0, listed.output + records = json.loads(listed.output)['records'] + selectors = [record['selector'] for record in records] + assert len(set(selectors)) == len(profiles) + assert records[0]['id'] == records[1]['id'] == shared_prefix + assert records[2]['id'] == records[3]['id'] == 'duplicate-id' + + profiles[0]['name'] = 'Changed collision' + with app.app_context(): + path.write_text(json.dumps({ + 'schema_version': CURRENT_STORAGE_VERSIONS['profiles'], + 'profiles': profiles, + }), encoding='utf-8') + changed = runner.invoke(args=[ + 'connection-store', 'delete', '--username', username, + '--kind', 'profiles', '--selector', records[0]['selector'], + '--confirm-offline', + ]) + assert changed.exit_code != 0 + assert 'list the store again' in changed.output + + deleted = runner.invoke(args=[ + 'connection-store', 'delete', '--username', username, + '--kind', 'profiles', '--selector', records[3]['selector'], + '--confirm-offline', + ]) + assert deleted.exit_code == 0, deleted.output + with app.app_context(): + persisted = json.loads(path.read_text(encoding='utf-8'))['profiles'] + assert [profile['name'] for profile in persisted] == [ + 'Changed collision', + 'Second collision', + 'First duplicate', + ] + + stale = runner.invoke(args=[ + 'connection-store', 'delete', '--username', username, + '--kind', 'profiles', '--selector', records[3]['selector'], + '--confirm-offline', + ]) + assert stale.exit_code != 0 + assert 'list the store again' in stale.output + with app.app_context(): + persisted = json.loads(path.read_text(encoding='utf-8'))['profiles'] + assert [profile['name'] for profile in persisted] == [ + 'Changed collision', + 'Second collision', + 'First duplicate', + ] + + +def test_parallel_recovery_deletes_are_cross_process_serialized( + app, + monkeypatch, + tmp_path, +): + import multiprocessing + import config + from app import profile_manager + from app.models import db + + try: + process_context = multiprocessing.get_context('fork') + except ValueError: + pytest.skip('requires multiprocessing fork support') + + username = 'profile-recovery-cross-process' + user_id = _create_user(app, username) + profiles = [ + {'id': 'first', 'name': 'First'}, + {'id': 'second', 'name': 'Second'}, + ] + with app.app_context(): + assert profile_manager.save_profiles(user_id, profiles) is True + summaries, error = profile_manager.load_profile_recovery_summaries( + user_id + ) + assert error is None + path = profile_manager.get_user_profiles_file(user_id) + db.session.remove() + db.engine.dispose() + + monkeypatch.setattr(config, 'BACKUP_TEMP_DIR', tmp_path / 'operations') + monkeypatch.setattr(config, 'BACKUP_OPERATION_TIMEOUT', 2) + common = [ + 'connection-store', 'delete', '--username', username, + '--kind', 'profiles', '--confirm-offline', '--selector', + ] + # The first process removes ordinal 1. Once serialized, ordinal 0 retains + # its selector and can be removed by the contender without a lost update. + first_arguments = [*common, summaries[1]['selector']] + second_arguments = [*common, summaries[0]['selector']] + first_loaded = process_context.Event() + release_first = process_context.Event() + contender_blocked = process_context.Event() + results = process_context.Queue() + first = process_context.Process( + target=_run_paused_profile_delete_child, + args=( + app, + first_arguments, + first_loaded, + release_first, + results, + ), + ) + second = process_context.Process( + target=_run_contended_profile_delete_child, + args=(app, second_arguments, contender_blocked, results), + ) + try: + first.start() + assert first_loaded.wait(timeout=3) + second.start() + assert contender_blocked.wait(timeout=3) + release_first.set() + outcomes = [results.get(timeout=5), results.get(timeout=5)] + finally: + release_first.set() + for process in (first, second): + process.join(timeout=5) + if process.is_alive(): + process.terminate() + process.join(timeout=2) + + assert first.exitcode == 0 + assert second.exitcode == 0 + assert sorted(outcome[1] for outcome in outcomes) == [0, 0] + assert all( + 'Deleted one profile recovery record.' in outcome[2] + for outcome in outcomes + ) + persisted = json.loads(path.read_text(encoding='utf-8')) + assert persisted['profiles'] == [] + + +@pytest.mark.parametrize('subcommand', ('list', 'delete')) +def test_connection_store_cli_reports_cross_process_operation_busy( + app, + monkeypatch, + tmp_path, + subcommand, +): + import multiprocessing + import config + from app import profile_manager + from app.backup_coordination import operation_lock + from app.models import db + + try: + process_context = multiprocessing.get_context('fork') + except ValueError: + pytest.skip('requires multiprocessing fork support') + + username = f'profile-recovery-busy-{subcommand}' + user_id = _create_user(app, username) + with app.app_context(): + assert profile_manager.save_profiles(user_id, [ + {'id': 'keep', 'name': 'Keep'}, + ]) is True + summaries, error = profile_manager.load_profile_recovery_summaries( + user_id + ) + assert error is None + path = profile_manager.get_user_profiles_file(user_id) + original = path.read_bytes() + db.session.remove() + db.engine.dispose() + + monkeypatch.setattr(config, 'BACKUP_TEMP_DIR', tmp_path / 'operations') + monkeypatch.setattr(config, 'BACKUP_OPERATION_TIMEOUT', 0.1) + arguments = [ + 'connection-store', subcommand, '--username', username, + '--kind', 'profiles', '--confirm-offline', + ] + if subcommand == 'delete': + arguments.extend(('--selector', summaries[0]['selector'])) + + start = process_context.Event() + ready = process_context.Event() + initialization_started = process_context.Event() + results = process_context.Queue() + process = process_context.Process( + target=_run_cli_child, + args=(app, arguments, results), + kwargs={ + 'start': start, + 'ready': ready, + 'initialization_started': initialization_started, + }, + ) + process.start() + try: + assert ready.wait(timeout=3) + with operation_lock(): + start.set() + outcome = results.get(timeout=5) + finally: + start.set() + process.join(timeout=5) + if process.is_alive(): + process.terminate() + process.join(timeout=2) + + assert process.exitcode == 0 + assert outcome[0] == 'result' + assert outcome[1] != 0 + assert 'another backup or restore operation is active' in outcome[2] + assert initialization_started.is_set() is False + assert path.read_bytes() == original diff --git a/tests/test_entrypoint.py b/tests/test_entrypoint.py new file mode 100644 index 00000000..1e0b6655 --- /dev/null +++ b/tests/test_entrypoint.py @@ -0,0 +1,75 @@ +"""Container entrypoint persistence-boundary tests.""" + +import os +from pathlib import Path +import stat +import subprocess +import sys + + +ENTRYPOINT = Path(__file__).parents[1] / 'entrypoint.sh' + + +def _run_entrypoint(data_dir, *, secret=None): + environment = { + **os.environ, + 'PATH': f'{Path(sys.executable).parent}:{os.environ.get("PATH", "")}', + 'DATA_DIR': str(data_dir), + } + if secret is None: + environment.pop('SECRET_KEY', None) + else: + environment['SECRET_KEY'] = secret + return subprocess.run( + [ + '/bin/bash', + str(ENTRYPOINT), + sys.executable, + '-c', + ( + 'import os; ' + 'print(os.environ["DATA_DIR"]); ' + 'print(os.environ["SECRET_KEY"])' + ), + ], + env=environment, + text=True, + capture_output=True, + check=False, + ) + + +def test_custom_data_dir_owns_generated_secret_and_runtime_directories(tmp_path): + data_dir = tmp_path / 'custom-data' + + result = _run_entrypoint(data_dir) + + assert result.returncode == 0, result.stderr + secret_file = data_dir / 'secret_key' + secret = secret_file.read_text(encoding='utf-8').strip() + assert len(secret) == 64 + assert result.stdout.splitlines()[-2:] == [str(data_dir.resolve()), secret] + assert stat.S_IMODE(secret_file.stat().st_mode) == 0o600 + assert (data_dir / 'logs').is_dir() + assert (data_dir / 'keys').is_dir() + + +def test_explicit_secret_wins_without_creating_a_split_secret_file(tmp_path): + data_dir = tmp_path / 'external-secret-data' + + result = _run_entrypoint(data_dir, secret='external-secret-value') + + assert result.returncode == 0, result.stderr + assert result.stdout.splitlines()[-2:] == [ + str(data_dir.resolve()), + 'external-secret-value', + ] + assert not (data_dir / 'secret_key').exists() + + +def test_relative_data_dir_is_rejected_before_writing_state(tmp_path): + result = _run_entrypoint(Path('relative-data')) + + assert result.returncode == 1 + assert result.stdout == '' + assert 'DATA_DIR must be an absolute path' in result.stderr diff --git a/tests/test_factor_bootstrap.py b/tests/test_factor_bootstrap.py new file mode 100644 index 00000000..4e64ebe3 --- /dev/null +++ b/tests/test_factor_bootstrap.py @@ -0,0 +1,88 @@ +"""Initial-factor bootstrap codes are narrow, expiring, and one-use.""" + +from datetime import datetime, timedelta, timezone + + +def _github_only_user(app, username='bootstrap-user'): + from app.models import GitHubIdentity, User, db + + with app.app_context(): + user = User(username=username, password_hash='not-a-known-password') + db.session.add(user) + db.session.flush() + db.session.add(GitHubIdentity( + user_id=user.id, + github_user_id=f'github-{user.id}', + login=username, + provisioned_by_github=True, + )) + db.session.commit() + return user.id + + +def test_new_bootstrap_code_revokes_previous_and_is_action_bound(app): + from app.factor_bootstrap import ( + consume_factor_bootstrap, + has_live_factor_bootstrap, + issue_factor_bootstrap, + ) + from app.models import FactorBootstrapToken, User, db + + user_id = _github_only_user(app) + with app.app_context(): + user = db.session.get(User, user_id) + first, _expiry = issue_factor_bootstrap(user, 'passkey.enroll') + second, _expiry = issue_factor_bootstrap(user, 'passkey.enroll') + + assert first != second + assert FactorBootstrapToken.query.count() == 1 + assert first not in FactorBootstrapToken.query.one().token_hash + assert has_live_factor_bootstrap(user, 'passkey.enroll') is True + assert consume_factor_bootstrap( + user, 'totp.enroll', second + ) is False + assert consume_factor_bootstrap( + user, 'passkey.enroll', first + ) is False + assert consume_factor_bootstrap( + user, 'passkey.enroll', second + ) is True + assert consume_factor_bootstrap( + user, 'passkey.enroll', second + ) is False + + +def test_bootstrap_code_expires_at_the_exact_deadline(app): + from app.factor_bootstrap import ( + consume_factor_bootstrap, + has_live_factor_bootstrap, + issue_factor_bootstrap, + ) + from app.models import User, db + + user_id = _github_only_user(app, 'expiring-bootstrap-user') + issued_at = datetime.now(timezone.utc) + with app.app_context(): + user = db.session.get(User, user_id) + token, expiry = issue_factor_bootstrap( + user, + 'passkey.enroll', + now=issued_at, + ) + + assert has_live_factor_bootstrap( + user, + 'passkey.enroll', + now=expiry.replace(tzinfo=timezone.utc) - timedelta(microseconds=1), + ) is True + assert has_live_factor_bootstrap( + user, + 'passkey.enroll', + now=expiry.replace(tzinfo=timezone.utc), + ) is False + assert consume_factor_bootstrap( + user, + 'passkey.enroll', + token, + now=expiry.replace(tzinfo=timezone.utc), + ) is False diff --git a/tests/test_file_service.py b/tests/test_file_service.py index 24dbd50d..104a3ac0 100644 --- a/tests/test_file_service.py +++ b/tests/test_file_service.py @@ -1,4 +1,5 @@ from types import SimpleNamespace +from threading import Event, Thread import pytest @@ -21,6 +22,112 @@ def list_directory(self, source, path): return [{'name': 'config.yml'}], None +class RecordingListing: + def __init__(self, pages): + self.pages = list(pages) + self.read_calls = [] + self.close_calls = 0 + + def read_page(self, page_size): + self.read_calls.append(page_size) + page = self.pages.pop(0) + return page, None, bool(self.pages) + + def close(self): + self.close_calls += 1 + + +class PagingBackend(RecordingBackend): + def __init__(self, *listings): + super().__init__() + self.listings = list(listings) + + def open_directory_listing(self, source, path): + self.calls.append(('open', source.source_id, path)) + return self.listings.pop(0), None + + +class ChannelConstrainedPagingBackend(PagingBackend): + def __init__(self, *listings): + super().__init__(*listings) + self.active_listing = None + + def open_directory_listing(self, source, path): + if ( + self.active_listing is not None + and self.active_listing.close_calls == 0 + ): + return None, 'No channel slots available' + listing, error = super().open_directory_listing(source, path) + self.active_listing = listing + return listing, error + + +class BlockingOpenBackend(PagingBackend): + def __init__(self, *listings): + super().__init__(*listings) + self.open_started = Event() + self.allow_open = Event() + + def open_directory_listing(self, source, path): + self.calls.append(('open', source.source_id, path)) + self.open_started.set() + assert self.allow_open.wait(2) + return self.listings.pop(0), None + + +class BlockingCloseListing(RecordingListing): + def __init__(self, pages): + super().__init__(pages) + self.close_started = Event() + self.allow_close = Event() + + def close(self): + self.close_started.set() + assert self.allow_close.wait(2) + super().close() + + +class BlockingContinuationListing(RecordingListing): + def __init__(self, pages): + super().__init__(pages) + self.continuation_started = Event() + self.allow_continuation = Event() + + def read_page(self, page_size): + if self.read_calls: + self.continuation_started.set() + assert self.allow_continuation.wait(2) + return super().read_page(page_size) + + +class BlockingInitialPageListing(RecordingListing): + def __init__(self, pages): + super().__init__(pages) + self.read_started = Event() + self.allow_read = Event() + + def read_page(self, page_size): + self.read_started.set() + assert self.allow_read.wait(2) + return super().read_page(page_size) + + +class FakeTimer: + def __init__(self, _interval, function, args): + self.function = function + self.args = args + self.daemon = False + self.started = False + self.cancelled = False + + def start(self): + self.started = True + + def cancel(self): + self.cancelled = True + + def resolved_source(*capabilities, backend=None): backend = backend or RecordingBackend() return ResolvedFileSource( @@ -93,3 +200,1119 @@ def test_recursive_delete_requires_recursive_capability_before_backend_call(): service.delete('smb-quick:owned', user_id=7, path='/reports') assert backend.calls == [] + + +def test_directory_pages_resume_one_backend_enumeration_with_opaque_cursor( + monkeypatch, +): + import app.file_service as file_service_module + + monkeypatch.setattr(file_service_module, 'Timer', FakeTimer) + listing = RecordingListing([ + [{'name': 'one'}, {'name': 'two'}], + [{'name': 'three'}], + ]) + backend = PagingBackend(listing) + source = resolved_source(FileCapability.LIST, backend=backend) + service = FileService( + SimpleNamespace(resolve=lambda _source_id, _user_id: source) + ) + + first, error, cursor = service.list_directory_page( + source.source_id, + user_id=7, + path='/srv', + client_id='socket-a', + ) + second, error2, next_cursor = service.list_directory_page( + source.source_id, + user_id=7, + path='/srv', + cursor=cursor, + client_id='socket-a', + ) + + assert error is None + assert error2 is None + assert first == [{'name': 'one'}, {'name': 'two'}] + assert second == [{'name': 'three'}] + assert isinstance(cursor, str) and cursor.startswith('v1.') + assert next_cursor is None + assert backend.calls == [('open', source.source_id, '/srv')] + assert len(listing.read_calls) == 2 + assert listing.close_calls == 1 + + +def test_directory_cursor_replay_tampering_and_binding_do_not_advance_listing( + monkeypatch, +): + import app.file_service as file_service_module + + monkeypatch.setattr(file_service_module, 'Timer', FakeTimer) + listing = RecordingListing([ + [{'name': 'one'}], + [{'name': 'two'}], + [{'name': 'three'}], + ]) + backend = PagingBackend(listing) + source = resolved_source(FileCapability.LIST, backend=backend) + service = FileService( + SimpleNamespace(resolve=lambda _source_id, _user_id: source) + ) + _page, _error, cursor = service.list_directory_page( + source.source_id, + user_id=7, + path='/srv', + client_id='socket-a', + ) + + tampered = cursor[:-1] + ('0' if cursor[-1] != '0' else '1') + for candidate, user_id, path, client_id in ( + (tampered, 7, '/srv', 'socket-a'), + (cursor, 8, '/srv', 'socket-a'), + (cursor, 7, '/other', 'socket-a'), + (cursor, 7, '/srv', 'socket-b'), + ): + page, error, next_cursor = service.list_directory_page( + source.source_id, + user_id=user_id, + path=path, + cursor=candidate, + client_id=client_id, + ) + assert page is None + assert error == 'Invalid or expired directory cursor' + assert next_cursor is None + + page, error, next_cursor = service.list_directory_page( + source.source_id, + user_id=7, + path='/srv', + cursor=cursor, + client_id='socket-a', + ) + assert error is None + assert page == [{'name': 'two'}] + assert len(listing.read_calls) == 2 + + replay, replay_error, replay_cursor = service.list_directory_page( + source.source_id, + user_id=7, + path='/srv', + cursor=cursor, + client_id='socket-a', + ) + assert replay is None + assert replay_error == 'Invalid or expired directory cursor' + assert replay_cursor is None + assert len(listing.read_calls) == 2 + service.discard_directory_snapshots(user_id=7) + assert listing.close_calls == 1 + + +def test_directory_snapshot_cancel_requires_exact_cursor_owner_source_and_socket( + monkeypatch, +): + import app.file_service as file_service_module + + monkeypatch.setattr(file_service_module, 'Timer', FakeTimer) + listing = RecordingListing([ + [{'name': 'one'}], + [{'name': 'two'}], + ]) + backend = PagingBackend(listing) + source = resolved_source(FileCapability.LIST, backend=backend) + service = FileService( + SimpleNamespace(resolve=lambda _source_id, _user_id: source) + ) + _page, _error, cursor = service.list_directory_page( + source.source_id, + user_id=7, + path='/srv', + client_id='socket-a', + ) + tampered = cursor[:-1] + ('0' if cursor[-1] != '0' else '1') + + for candidate, user_id, source_id, client_id in ( + (tampered, 7, source.source_id, 'socket-a'), + (cursor, 8, source.source_id, 'socket-a'), + (cursor, 7, 'sftp-session:other', 'socket-a'), + (cursor, 7, source.source_id, 'socket-b'), + ): + assert service.cancel_directory_snapshot( + candidate, + user_id=user_id, + source_id=source_id, + client_id=client_id, + ) is False + assert listing.close_calls == 0 + + assert service.cancel_directory_snapshot( + cursor, + user_id=7, + source_id=source.source_id, + client_id='socket-a', + ) is True + assert listing.close_calls == 1 + assert service._directory_snapshots == {} + assert service.cancel_directory_snapshot( + cursor, + user_id=7, + source_id=source.source_id, + client_id='socket-a', + ) is False + assert listing.close_calls == 1 + + +def test_directory_snapshot_cancel_waits_for_in_progress_exact_close( + monkeypatch, +): + import app.file_service as file_service_module + + monkeypatch.setattr(file_service_module, 'Timer', FakeTimer) + listing = BlockingCloseListing([ + [{'name': 'one'}], + [{'name': 'two'}], + ]) + backend = PagingBackend(listing) + source = resolved_source(FileCapability.LIST, backend=backend) + service = FileService( + SimpleNamespace(resolve=lambda _source_id, _user_id: source) + ) + _page, _error, cursor = service.list_directory_page( + source.source_id, + user_id=7, + path='/srv', + client_id='socket-a', + ) + snapshot_id, _offset, _signature = service._parse_directory_cursor( + cursor + ) + state = service._directory_snapshots[snapshot_id] + with service._directory_snapshot_lock: + state['status'] = 'closing' + + cancellations = [] + cancellation = Thread(target=lambda: cancellations.append( + service.cancel_directory_snapshot( + cursor, + user_id=7, + source_id=source.source_id, + client_id='socket-a', + ) + )) + cancellation.start() + cancellation.join(0.1) + + assert cancellation.is_alive() + assert listing.close_calls == 0 + closer = Thread(target=lambda: service._close_directory_state( + snapshot_id, + state, + )) + closer.start() + assert listing.close_started.wait(1) + assert cancellation.is_alive() + listing.allow_close.set() + closer.join(2) + cancellation.join(2) + + assert not closer.is_alive() + assert not cancellation.is_alive() + assert cancellations == [True] + assert listing.close_calls == 1 + assert service._directory_snapshots == {} + + +def test_directory_request_cancel_requires_exact_request_owner_source_and_socket( + monkeypatch, +): + import app.file_service as file_service_module + + monkeypatch.setattr(file_service_module, 'Timer', FakeTimer) + listing = RecordingListing([ + [{'name': 'one'}], + [{'name': 'two'}], + ]) + backend = PagingBackend(listing) + source = resolved_source(FileCapability.LIST, backend=backend) + service = FileService( + SimpleNamespace(resolve=lambda _source_id, _user_id: source) + ) + service.list_directory_page( + source.source_id, + user_id=7, + path='/srv', + client_id='socket-a', + request_id='left:directory:1', + ) + + for request_id, user_id, source_id, client_id in ( + ('invalid request id', 7, source.source_id, 'socket-a'), + ('left:directory:2', 7, source.source_id, 'socket-a'), + ('left:directory:1', 8, source.source_id, 'socket-a'), + ('left:directory:1', 7, 'sftp-session:other', 'socket-a'), + ('left:directory:1', 7, source.source_id, 'socket-b'), + ): + assert service.cancel_directory_request( + request_id, + user_id=user_id, + source_id=source_id, + client_id=client_id, + ) is False + assert listing.close_calls == 0 + + assert service.cancel_directory_request( + 'left:directory:1', + user_id=7, + source_id=source.source_id, + client_id='socket-a', + ) is True + assert listing.close_calls == 1 + assert service._directory_snapshots == {} + assert service.cancel_directory_request( + 'left:directory:1', + user_id=7, + source_id=source.source_id, + client_id='socket-a', + ) is False + assert listing.close_calls == 1 + + +def test_directory_request_cancel_does_not_cross_socket_snapshots( + monkeypatch, +): + import app.file_service as file_service_module + + monkeypatch.setattr(file_service_module, 'Timer', FakeTimer) + first = RecordingListing([[{'name': 'a'}], [{'name': 'b'}]]) + second = RecordingListing([[{'name': 'c'}], [{'name': 'd'}]]) + backend = PagingBackend(first, second) + source = resolved_source(FileCapability.LIST, backend=backend) + service = FileService( + SimpleNamespace(resolve=lambda _source_id, _user_id: source) + ) + for client_id, path in ( + ('socket-a', '/one'), + ('socket-b', '/two'), + ): + service.list_directory_page( + source.source_id, + user_id=7, + path=path, + client_id=client_id, + request_id='left:directory:shared', + ) + + assert service.cancel_directory_request( + 'left:directory:shared', + user_id=7, + source_id=source.source_id, + client_id='socket-a', + ) is True + assert first.close_calls == 1 + assert second.close_calls == 0 + assert len(service._directory_snapshots) == 1 + + service.discard_directory_snapshots() + assert second.close_calls == 1 + + +def test_directory_request_cancel_marks_an_opening_snapshot_for_retirement( + monkeypatch, +): + import app.file_service as file_service_module + + monkeypatch.setattr(file_service_module, 'Timer', FakeTimer) + listing = RecordingListing([[{'name': 'one'}], [{'name': 'two'}]]) + backend = BlockingOpenBackend(listing) + source = resolved_source(FileCapability.LIST, backend=backend) + service = FileService( + SimpleNamespace(resolve=lambda _source_id, _user_id: source) + ) + results = [] + worker = Thread(target=lambda: results.append( + service.list_directory_page( + source.source_id, + user_id=7, + path='/srv', + client_id='socket-a', + request_id='left:directory:opening', + ) + )) + worker.start() + assert backend.open_started.wait(1) + + assert service.cancel_directory_request( + 'left:directory:opening', + user_id=7, + source_id=source.source_id, + client_id='socket-b', + ) is False + cancellations = [] + cancellation = Thread(target=lambda: cancellations.append( + service.cancel_directory_request( + 'left:directory:opening', + user_id=7, + source_id=source.source_id, + client_id='socket-a', + ) + )) + cancellation.start() + cancellation.join(0.1) + assert tuple(service._directory_snapshots.values())[0]['status'] == ( + 'cancelled' + ) + assert cancellation.is_alive() + assert listing.close_calls == 0 + + backend.allow_open.set() + worker.join(2) + cancellation.join(2) + + assert not worker.is_alive() + assert not cancellation.is_alive() + assert cancellations == [True] + assert results == [(None, 'Directory listing cancelled', None)] + assert service._directory_snapshots == {} + assert listing.close_calls == 1 + + +def test_duplicate_opening_directory_cancels_do_not_add_waiters(monkeypatch): + import app.file_service as file_service_module + + monkeypatch.setattr(file_service_module, 'Timer', FakeTimer) + listing = RecordingListing([[{'name': 'one'}], [{'name': 'two'}]]) + backend = BlockingOpenBackend(listing) + source = resolved_source(FileCapability.LIST, backend=backend) + service = FileService( + SimpleNamespace(resolve=lambda _source_id, _user_id: source) + ) + worker = Thread(target=lambda: service.list_directory_page( + source.source_id, + user_id=7, + path='/srv', + client_id='socket-a', + request_id='left:directory:duplicates', + )) + worker.start() + assert backend.open_started.wait(1) + + first_results = [] + first = Thread(target=lambda: first_results.append( + service.cancel_directory_request( + 'left:directory:duplicates', + user_id=7, + source_id=source.source_id, + client_id='socket-a', + ) + )) + first.start() + first.join(0.1) + assert first.is_alive() + + duplicate_results = [] + duplicates = [ + Thread(target=lambda: duplicate_results.append( + service.cancel_directory_request( + 'left:directory:duplicates', + user_id=7, + source_id=source.source_id, + client_id='socket-a', + ) + )) + for _index in range(16) + ] + for duplicate in duplicates: + duplicate.start() + for duplicate in duplicates: + duplicate.join(1) + + assert all(not duplicate.is_alive() for duplicate in duplicates) + assert duplicate_results == [True] * 16 + assert first.is_alive() + + backend.allow_open.set() + worker.join(2) + first.join(2) + assert not worker.is_alive() + assert not first.is_alive() + assert first_results == [True] + assert listing.close_calls == 1 + + +def test_duplicate_cursor_cancels_return_while_elected_close_is_slow( + monkeypatch, +): + import app.file_service as file_service_module + + monkeypatch.setattr(file_service_module, 'Timer', FakeTimer) + listing = BlockingCloseListing([ + [{'name': 'one'}], + [{'name': 'two'}], + ]) + backend = PagingBackend(listing) + source = resolved_source(FileCapability.LIST, backend=backend) + service = FileService( + SimpleNamespace(resolve=lambda _source_id, _user_id: source) + ) + _page, _error, cursor = service.list_directory_page( + source.source_id, + user_id=7, + path='/srv', + client_id='socket-a', + ) + first_results = [] + first = Thread(target=lambda: first_results.append( + service.cancel_directory_snapshot( + cursor, + user_id=7, + source_id=source.source_id, + client_id='socket-a', + ) + )) + first.start() + assert listing.close_started.wait(1) + + duplicate_results = [] + duplicates = [ + Thread(target=lambda: duplicate_results.append( + service.cancel_directory_snapshot( + cursor, + user_id=7, + source_id=source.source_id, + client_id='socket-a', + ) + )) + for _index in range(16) + ] + for duplicate in duplicates: + duplicate.start() + for duplicate in duplicates: + duplicate.join(1) + + assert all(not duplicate.is_alive() for duplicate in duplicates) + assert duplicate_results == [True] * 16 + assert first.is_alive() + + listing.allow_close.set() + first.join(2) + assert not first.is_alive() + assert first_results == [True] + assert listing.close_calls == 1 + + +def test_duplicate_cursor_cancels_return_while_terminal_page_closes( + monkeypatch, +): + import app.file_service as file_service_module + + monkeypatch.setattr(file_service_module, 'Timer', FakeTimer) + listing = BlockingCloseListing([ + [{'name': 'one'}], + [{'name': 'two'}], + ]) + backend = PagingBackend(listing) + source = resolved_source(FileCapability.LIST, backend=backend) + service = FileService( + SimpleNamespace(resolve=lambda _source_id, _user_id: source) + ) + _page, _error, cursor = service.list_directory_page( + source.source_id, + user_id=7, + path='/srv', + client_id='socket-a', + ) + + continuation_results = [] + continuation = Thread(target=lambda: continuation_results.append( + service.list_directory_page( + source.source_id, + user_id=7, + path='/srv', + cursor=cursor, + client_id='socket-a', + ) + )) + continuation.start() + assert listing.close_started.wait(1) + + duplicate_results = [] + duplicates = [ + Thread(target=lambda: duplicate_results.append( + service.cancel_directory_snapshot( + cursor, + user_id=7, + source_id=source.source_id, + client_id='socket-a', + ) + )) + for _index in range(16) + ] + for duplicate in duplicates: + duplicate.start() + for duplicate in duplicates: + duplicate.join(1) + + assert all(not duplicate.is_alive() for duplicate in duplicates) + assert duplicate_results == [True] * 16 + assert continuation.is_alive() + + listing.allow_close.set() + continuation.join(2) + assert not continuation.is_alive() + assert continuation_results == [([{'name': 'two'}], None, None)] + assert listing.close_calls == 1 + + +def test_directory_request_cancel_waits_for_initial_page_read_before_close( + monkeypatch, +): + import app.file_service as file_service_module + + monkeypatch.setattr(file_service_module, 'Timer', FakeTimer) + listing = BlockingInitialPageListing([ + [{'name': 'one'}], + [{'name': 'two'}], + ]) + backend = PagingBackend(listing) + source = resolved_source(FileCapability.LIST, backend=backend) + service = FileService( + SimpleNamespace(resolve=lambda _source_id, _user_id: source) + ) + results = [] + worker = Thread(target=lambda: results.append( + service.list_directory_page( + source.source_id, + user_id=7, + path='/srv', + client_id='socket-a', + request_id='left:directory:reading', + ) + )) + worker.start() + assert listing.read_started.wait(1) + + cancellations = [] + cancellation = Thread(target=lambda: cancellations.append( + service.cancel_directory_request( + 'left:directory:reading', + user_id=7, + source_id=source.source_id, + client_id='socket-a', + ) + )) + cancellation.start() + cancellation.join(0.1) + + assert cancellation.is_alive() + assert listing.close_calls == 0 + listing.allow_read.set() + worker.join(2) + cancellation.join(2) + + assert not worker.is_alive() + assert not cancellation.is_alive() + assert cancellations == [True] + assert results == [(None, 'Directory listing cancelled', None)] + assert service._directory_snapshots == {} + assert listing.close_calls == 1 + + +def test_directory_request_cancel_closes_before_fifo_replacement_opens( + monkeypatch, +): + import app.file_service as file_service_module + + monkeypatch.setattr(file_service_module, 'Timer', FakeTimer) + first_listing = RecordingListing([ + [{'name': 'one'}], + [{'name': 'two'}], + ]) + replacement_listing = RecordingListing([[{'name': 'replacement'}]]) + backend = ChannelConstrainedPagingBackend( + first_listing, + replacement_listing, + ) + source = resolved_source(FileCapability.LIST, backend=backend) + service = FileService( + SimpleNamespace(resolve=lambda _source_id, _user_id: source) + ) + first = service.list_directory_page( + source.source_id, + user_id=7, + path='/old', + client_id='socket-a', + request_id='left:directory:old', + ) + cancelled = service.cancel_directory_request( + 'left:directory:old', + user_id=7, + source_id=source.source_id, + client_id='socket-a', + ) + replacement = service.list_directory_page( + source.source_id, + user_id=7, + path='/replacement', + client_id='socket-a', + request_id='left:directory:new', + ) + + assert first[0] == [{'name': 'one'}] + assert first[1] is None + assert first[2] is not None + assert cancelled is True + assert first_listing.close_calls == 1 + assert replacement == ([{'name': 'replacement'}], None, None) + assert backend.calls == [ + ('open', source.source_id, '/old'), + ('open', source.source_id, '/replacement'), + ] + assert replacement_listing.close_calls == 1 + + +def test_directory_snapshot_cancel_racing_continuation_accepts_issued_cursor( + monkeypatch, +): + import app.file_service as file_service_module + + monkeypatch.setattr(file_service_module, 'Timer', FakeTimer) + listing = BlockingContinuationListing([ + [{'name': 'one'}], + [{'name': 'two'}], + [{'name': 'three'}], + ]) + backend = PagingBackend(listing) + source = resolved_source(FileCapability.LIST, backend=backend) + service = FileService( + SimpleNamespace(resolve=lambda _source_id, _user_id: source) + ) + _page, _error, cursor = service.list_directory_page( + source.source_id, + user_id=7, + path='/srv', + client_id='socket-a', + ) + continued = [] + cancelled = [] + continuation = Thread(target=lambda: continued.append( + service.list_directory_page( + source.source_id, + user_id=7, + path='/srv', + cursor=cursor, + client_id='socket-a', + ) + )) + continuation.start() + assert listing.continuation_started.wait(1) + cancellation = Thread(target=lambda: cancelled.append( + service.cancel_directory_snapshot( + cursor, + user_id=7, + source_id=source.source_id, + client_id='socket-a', + ) + )) + cancellation.start() + cancellation.join(0.1) + assert cancellation.is_alive() + + duplicate_results = [] + duplicates = [ + Thread(target=lambda: duplicate_results.append( + service.cancel_directory_snapshot( + cursor, + user_id=7, + source_id=source.source_id, + client_id='socket-a', + ) + )) + for _index in range(16) + ] + for duplicate in duplicates: + duplicate.start() + for duplicate in duplicates: + duplicate.join(1) + + assert all(not duplicate.is_alive() for duplicate in duplicates) + assert duplicate_results == [True] * 16 + + listing.allow_continuation.set() + continuation.join(2) + cancellation.join(2) + + assert not continuation.is_alive() + assert not cancellation.is_alive() + assert cancelled == [True] + assert continued[0][:2] == ([{'name': 'two'}], None) + assert continued[0][2] is not None + assert listing.close_calls == 1 + assert service._directory_snapshots == {} + + +def test_issued_cursor_cancel_closes_before_replacement_listing_opens( + monkeypatch, +): + import app.file_service as file_service_module + + monkeypatch.setattr(file_service_module, 'Timer', FakeTimer) + first_listing = RecordingListing([ + [{'name': 'one'}], + [{'name': 'two'}], + [{'name': 'three'}], + ]) + replacement_listing = RecordingListing([[{'name': 'replacement'}]]) + backend = ChannelConstrainedPagingBackend( + first_listing, + replacement_listing, + ) + source = resolved_source(FileCapability.LIST, backend=backend) + service = FileService( + SimpleNamespace(resolve=lambda _source_id, _user_id: source) + ) + _page, _error, first_cursor = service.list_directory_page( + source.source_id, + user_id=7, + path='/old', + client_id='socket-a', + ) + continued, continuation_error, next_cursor = service.list_directory_page( + source.source_id, + user_id=7, + path='/old', + cursor=first_cursor, + client_id='socket-a', + ) + + assert (continued, continuation_error) == ([{'name': 'two'}], None) + assert next_cursor is not None + assert service.cancel_directory_snapshot( + first_cursor, + user_id=7, + source_id=source.source_id, + client_id='socket-a', + ) is True + replacement = service.list_directory_page( + source.source_id, + user_id=7, + path='/replacement', + client_id='socket-a', + ) + + assert first_listing.close_calls == 1 + assert replacement == ([{'name': 'replacement'}], None, None) + assert backend.calls == [ + ('open', source.source_id, '/old'), + ('open', source.source_id, '/replacement'), + ] + assert replacement_listing.close_calls == 1 + + +def test_directory_snapshot_expiry_closes_backend_listing(monkeypatch): + import app.file_service as file_service_module + + monkeypatch.setattr(file_service_module, 'Timer', FakeTimer) + listing = RecordingListing([ + [{'name': 'one'}], + [{'name': 'two'}], + ]) + backend = PagingBackend(listing) + source = resolved_source(FileCapability.LIST, backend=backend) + service = FileService( + SimpleNamespace(resolve=lambda _source_id, _user_id: source) + ) + _page, _error, cursor = service.list_directory_page( + source.source_id, + user_id=7, + path='/', + client_id='socket-a', + ) + snapshot_id, _offset, _signature = service._parse_directory_cursor(cursor) + state = service._directory_snapshots[snapshot_id] + + service._expire_directory_snapshot( + snapshot_id, + state, + state['last_used'], + ) + + assert service._directory_snapshots == {} + assert listing.close_calls == 1 + page, error, next_cursor = service.list_directory_page( + source.source_id, + user_id=7, + path='/', + cursor=cursor, + client_id='socket-a', + ) + assert (page, error, next_cursor) == ( + None, + 'Invalid or expired directory cursor', + None, + ) + + +def test_directory_snapshot_limits_evict_and_close_oldest_per_user( + monkeypatch, +): + import config + import app.file_service as file_service_module + + monkeypatch.setattr(file_service_module, 'Timer', FakeTimer) + monkeypatch.setattr(config, 'REMOTE_LISTING_SNAPSHOT_MAX_PER_USER', 1) + first = RecordingListing([[{'name': 'a'}], [{'name': 'b'}]]) + second = RecordingListing([[{'name': 'c'}], [{'name': 'd'}]]) + backend = PagingBackend(first, second) + source = resolved_source(FileCapability.LIST, backend=backend) + service = FileService( + SimpleNamespace(resolve=lambda _source_id, _user_id: source) + ) + + service.list_directory_page( + source.source_id, user_id=7, path='/one', client_id='socket-a' + ) + service.list_directory_page( + source.source_id, user_id=7, path='/two', client_id='socket-a' + ) + + assert first.close_calls == 1 + assert second.close_calls == 0 + assert len(service._directory_snapshots) == 1 + service.discard_directory_snapshots() + assert second.close_calls == 1 + + +def test_directory_snapshot_capacity_is_reserved_before_backend_open( + monkeypatch, +): + import config + import app.file_service as file_service_module + + monkeypatch.setattr(file_service_module, 'Timer', FakeTimer) + monkeypatch.setattr(config, 'REMOTE_LISTING_SNAPSHOT_MAX_PER_USER', 1) + monkeypatch.setattr(config, 'REMOTE_LISTING_SNAPSHOT_MAX_STATES', 1) + listing = RecordingListing([[{'name': 'a'}], [{'name': 'b'}]]) + backend = BlockingOpenBackend(listing) + source = resolved_source(FileCapability.LIST, backend=backend) + service = FileService( + SimpleNamespace(resolve=lambda _source_id, _user_id: source) + ) + results = [] + first = Thread(target=lambda: results.append( + service.list_directory_page( + source.source_id, + user_id=7, + path='/one', + client_id='socket-a', + ) + )) + first.start() + assert backend.open_started.wait(1) + + blocked = service.list_directory_page( + source.source_id, + user_id=7, + path='/two', + client_id='socket-a', + ) + + assert blocked == (None, 'Too many active directory listings', None) + assert backend.calls == [('open', source.source_id, '/one')] + backend.allow_open.set() + first.join(2) + assert not first.is_alive() + assert results[0][1] is None + service.discard_directory_snapshots() + assert listing.close_calls == 1 + + +def test_directory_snapshot_capacity_remains_reserved_until_close_finishes( + monkeypatch, +): + import config + import app.file_service as file_service_module + + monkeypatch.setattr(file_service_module, 'Timer', FakeTimer) + monkeypatch.setattr(config, 'REMOTE_LISTING_SNAPSHOT_MAX_PER_USER', 1) + monkeypatch.setattr(config, 'REMOTE_LISTING_SNAPSHOT_MAX_STATES', 1) + first_listing = BlockingCloseListing([ + [{'name': 'a'}], + [{'name': 'b'}], + ]) + second_listing = RecordingListing([[{'name': 'c'}]]) + backend = PagingBackend(first_listing, second_listing) + source = resolved_source(FileCapability.LIST, backend=backend) + service = FileService( + SimpleNamespace(resolve=lambda _source_id, _user_id: source) + ) + _page, _error, cursor = service.list_directory_page( + source.source_id, + user_id=7, + path='/one', + client_id='socket-a', + ) + snapshot_id, _offset, _signature = service._parse_directory_cursor(cursor) + state = service._directory_snapshots[snapshot_id] + closer = Thread(target=lambda: service._expire_directory_snapshot( + snapshot_id, + state, + state['last_used'], + )) + closer.start() + assert first_listing.close_started.wait(1) + + blocked = service.list_directory_page( + source.source_id, + user_id=7, + path='/two', + client_id='socket-a', + ) + + assert blocked == (None, 'Too many active directory listings', None) + assert backend.calls == [('open', source.source_id, '/one')] + first_listing.allow_close.set() + closer.join(2) + assert not closer.is_alive() + page, error, next_cursor = service.list_directory_page( + source.source_id, + user_id=7, + path='/two', + client_id='socket-a', + ) + assert (page, error, next_cursor) == ([{'name': 'c'}], None, None) + assert first_listing.close_calls == 1 + assert second_listing.close_calls == 1 + + +def test_global_directory_capacity_does_not_evict_another_user(monkeypatch): + import config + import app.file_service as file_service_module + + monkeypatch.setattr(file_service_module, 'Timer', FakeTimer) + monkeypatch.setattr(config, 'REMOTE_LISTING_SNAPSHOT_MAX_PER_USER', 2) + monkeypatch.setattr(config, 'REMOTE_LISTING_SNAPSHOT_MAX_STATES', 1) + first_listing = RecordingListing([ + [{'name': 'a'}], + [{'name': 'b'}], + ]) + backend = PagingBackend(first_listing) + source = resolved_source(FileCapability.LIST, backend=backend) + service = FileService( + SimpleNamespace(resolve=lambda _source_id, _user_id: source) + ) + page, error, cursor = service.list_directory_page( + source.source_id, + user_id=8, + path='/owned-by-eight', + client_id='socket-eight', + ) + + blocked = service.list_directory_page( + source.source_id, + user_id=7, + path='/owned-by-seven', + client_id='socket-seven', + ) + + assert page == [{'name': 'a'}] + assert error is None + assert cursor is not None + assert blocked == (None, 'Too many active directory listings', None) + assert backend.calls == [ + ('open', source.source_id, '/owned-by-eight'), + ] + assert first_listing.close_calls == 0 + service.discard_directory_snapshots(user_id=8) + assert first_listing.close_calls == 1 + + +def test_discard_cancels_opening_directory_snapshot(monkeypatch): + import config + import app.file_service as file_service_module + + monkeypatch.setattr(file_service_module, 'Timer', FakeTimer) + monkeypatch.setattr(config, 'REMOTE_LISTING_SNAPSHOT_MAX_PER_USER', 1) + monkeypatch.setattr(config, 'REMOTE_LISTING_SNAPSHOT_MAX_STATES', 1) + listing = RecordingListing([[{'name': 'a'}], [{'name': 'b'}]]) + backend = BlockingOpenBackend(listing) + source = resolved_source(FileCapability.LIST, backend=backend) + service = FileService( + SimpleNamespace(resolve=lambda _source_id, _user_id: source) + ) + results = [] + worker = Thread(target=lambda: results.append( + service.list_directory_page( + source.source_id, + user_id=7, + path='/', + client_id='socket-a', + ) + )) + worker.start() + assert backend.open_started.wait(1) + + service.discard_directory_snapshots(user_id=7, client_id='socket-a') + assert len(service._directory_snapshots) == 1 + backend.allow_open.set() + worker.join(2) + + assert not worker.is_alive() + assert results == [(None, 'Directory listing cancelled', None)] + assert service._directory_snapshots == {} + assert listing.close_calls == 1 + + +def test_discard_directory_snapshots_is_scoped_to_socket(monkeypatch): + import app.file_service as file_service_module + + monkeypatch.setattr(file_service_module, 'Timer', FakeTimer) + first = RecordingListing([[{'name': 'a'}], [{'name': 'b'}]]) + second = RecordingListing([[{'name': 'c'}], [{'name': 'd'}]]) + backend = PagingBackend(first, second) + source = resolved_source(FileCapability.LIST, backend=backend) + service = FileService( + SimpleNamespace(resolve=lambda _source_id, _user_id: source) + ) + service.list_directory_page( + source.source_id, + user_id=7, + path='/one', + client_id='socket-a', + ) + service.list_directory_page( + source.source_id, + user_id=7, + path='/two', + client_id='socket-b', + ) + + service.discard_directory_snapshots( + user_id=7, + client_id='socket-a', + ) + + assert first.close_calls == 1 + assert second.close_calls == 0 + assert len(service._directory_snapshots) == 1 + service.discard_directory_snapshots() + assert second.close_calls == 1 + + +def test_directory_pagination_has_no_eager_full_listing_fallback(): + backend = RecordingBackend() + source = resolved_source(FileCapability.LIST, backend=backend) + service = FileService( + SimpleNamespace(resolve=lambda _source_id, _user_id: source) + ) + + result = service.list_directory_page( + source.source_id, + user_id=7, + path='/', + client_id='socket-a', + ) + + assert result == (None, 'Directory pagination unavailable', None) + assert backend.calls == [] diff --git a/tests/test_file_source_socket_events.py b/tests/test_file_source_socket_events.py index f042ab78..59ad9042 100644 --- a/tests/test_file_source_socket_events.py +++ b/tests/test_file_source_socket_events.py @@ -13,6 +13,17 @@ ) +@pytest.fixture(autouse=True) +def reset_file_control_budget_state(): + socket_events._file_control_budgets.clear() + socket_events._editor_save_budgets.clear() + socket_events._editor_retry_challenges.clear() + yield + socket_events._file_control_budgets.clear() + socket_events._editor_save_budgets.clear() + socket_events._editor_retry_challenges.clear() + + class ListingBackend: def __init__(self): self.calls = [] @@ -21,6 +32,20 @@ def list_directory(self, source, path): self.calls.append((source.source_id, path)) return [{'name': 'config.yml'}], None + def open_directory_listing(self, source, path): + self.calls.append((source.source_id, path)) + + class Listing: + @staticmethod + def read_page(_page_size): + return [{'name': 'config.yml'}], None, False + + @staticmethod + def close(): + return None + + return Listing(), None + def make_source(source_id, capabilities, backend, *, kind='sftp'): endpoint = 'host.test/Share' if kind == 'smb' else 'host.test:22' @@ -52,6 +77,425 @@ def capture(monkeypatch): return emitted, SimpleNamespace(id=7, username='operator') +def test_file_control_fields_are_bounded_before_resolution_or_reflection( + monkeypatch, +): + import config + + monkeypatch.setattr(config, 'FILE_CONTROL_MAX_PATH_BYTES', 8) + payload = { + 'source_id': 'sftp-session:owned', + 'request_id': 'left:directory:4', + 'remote_path': '/too-long', + } + + identity = socket_events._file_request_identity(payload, user_id=7) + + assert identity == { + 'source_id': None, + 'request_id': 'left:directory:4', + } + assert payload['remote_path'] is None + + +def test_file_control_metadata_has_a_token_bucket_per_user(monkeypatch): + import config + + monkeypatch.setattr(config, 'FILE_CONTROL_BYTES_PER_MINUTE', 10) + socket_events._file_control_budgets.clear() + + assert socket_events._consume_file_control_budget(7, 6, now=10) is True + assert socket_events._consume_file_control_budget(7, 5, now=11) is False + assert socket_events._consume_file_control_budget(8, 5, now=11) is True + assert socket_events._consume_file_control_budget(7, 5, now=71) is True + + +def test_file_control_budget_state_is_constant_size_under_event_spam( + monkeypatch, +): + import config + + monkeypatch.setattr(config, 'FILE_CONTROL_BYTES_PER_MINUTE', 10_000) + socket_events._file_control_budgets.clear() + + for index in range(1000): + assert socket_events._consume_file_control_budget( + 7, 1, now=index / 1000 + ) is True + + assert list(socket_events._file_control_budgets) == [7] + state = socket_events._file_control_budgets[7] + assert isinstance(state, tuple) + assert len(state) == 2 + + +def test_empty_file_control_budget_rejects_before_payload_walk(monkeypatch): + import config + + monkeypatch.setattr(config, 'FILE_CONTROL_BYTES_PER_MINUTE', 256) + monkeypatch.setattr(socket_events.time, 'monotonic', lambda: 10.0) + assert socket_events._consume_file_control_budget( + 7, 256, now=10.0 + ) is True + monkeypatch.setattr( + socket_events, + '_file_control_payload_cost', + lambda *_args, **_kwargs: pytest.fail( + 'empty budget still walked attacker payload' + ), + ) + payload = { + 'source_id': 'sftp-session:owned', + 'request_id': 'list:drained', + 'unknown': 'A' * 10_000, + } + + identity = socket_events._file_request_identity(payload, user_id=7) + + assert identity == {'source_id': None, 'request_id': 'list:drained'} + + +def test_insufficient_file_control_budget_is_exhausted(monkeypatch): + import config + + monkeypatch.setattr(config, 'FILE_CONTROL_BYTES_PER_MINUTE', 1000) + assert socket_events._consume_file_control_budget( + 7, 900, now=10.0 + ) is True + assert socket_events._consume_file_control_budget( + 7, 200, now=10.0 + ) is False + assert socket_events._file_control_budgets[7] == (0.0, 10.0) + + +def test_editor_save_budget_charges_exact_utf8_bytes_and_preserves_one_save( + monkeypatch, +): + import config + + monkeypatch.setattr(config, 'MAX_EDITOR_FILE_SIZE', 8) + monkeypatch.setattr(config, 'EDITOR_SAVE_BYTES_PER_MINUTE', 16) + monkeypatch.setattr(config, 'FILE_CONTROL_BYTES_PER_MINUTE', 10_000) + monkeypatch.setattr(socket_events.time, 'monotonic', lambda: 10.0) + + ascii_payload = { + 'source_id': 'sftp-session:owned', + 'request_id': 'save:ascii', + 'path': '/note.txt', + 'content': 'A' * 8, + } + multibyte_payload = { + 'source_id': 'sftp-session:owned', + 'request_id': 'save:utf8', + 'path': '/note.txt', + 'content': '\u00e9' * 4, + } + + assert socket_events._file_request_identity( + ascii_payload, + user_id=7, + allow_editor_content=True, + )['source_id'] == 'sftp-session:owned' + assert socket_events._file_request_identity( + multibyte_payload, + user_id=7, + allow_editor_content=True, + )['source_id'] == 'sftp-session:owned' + assert socket_events._editor_save_budgets[7] == (0.0, 10.0) + + rejected = { + 'source_id': 'sftp-session:owned', + 'request_id': 'save:third', + 'path': '/note.txt', + 'content': 'A', + } + assert socket_events._file_request_identity( + rejected, + user_id=7, + allow_editor_content=True, + )['source_id'] is None + + +def test_editor_retry_challenge_is_one_time_and_body_socket_bound(monkeypatch): + import config + + monkeypatch.setattr(config, 'MAX_EDITOR_FILE_SIZE', 32) + payload = { + 'source_id': 'smb-quick:owned', + 'path': '/note.txt', + 'content': 'original', + 'encoding': 'utf-8', + 'newline': 'lf', + 'expected_revision': 'a' * 64, + 'replace_strategy': 'recoverable_swap', + } + token = socket_events._issue_editor_retry_challenge( + payload, + 7, + 'socket-a', + ) + + assert socket_events._consume_editor_retry_challenge( + {**payload, 'content': 'modified', 'save_challenge': token}, + 7, + 'socket-a', + ) is False + assert socket_events._consume_editor_retry_challenge( + {**payload, 'save_challenge': token}, + 7, + 'socket-a', + ) is False + + second = socket_events._issue_editor_retry_challenge( + payload, + 7, + 'socket-a', + ) + assert socket_events._consume_editor_retry_challenge( + {**payload, 'save_challenge': second}, + 7, + 'socket-b', + ) is False + + +def test_empty_editor_budget_rejects_before_walking_editor_body(monkeypatch): + import config + + monkeypatch.setattr(config, 'EDITOR_SAVE_BYTES_PER_MINUTE', 8) + monkeypatch.setattr(config, 'FILE_CONTROL_BYTES_PER_MINUTE', 10_000) + monkeypatch.setattr(socket_events.time, 'monotonic', lambda: 10.0) + assert socket_events._consume_editor_save_budget( + 7, 8, now=10.0 + ) is True + monkeypatch.setattr( + socket_events, + '_file_control_payload_metrics', + lambda *_args, **_kwargs: pytest.fail( + 'empty editor budget still walked the editor body' + ), + ) + payload = { + 'source_id': 'sftp-session:owned', + 'request_id': 'save:drained', + 'path': '/note.txt', + 'content': 'A' * 8, + } + + identity = socket_events._file_request_identity( + payload, + user_id=7, + allow_editor_content=True, + ) + + assert identity == {'source_id': None, 'request_id': 'save:drained'} + + +def test_unknown_editor_retry_token_does_no_body_work_before_budget_gate( + monkeypatch, +): + import config + + monkeypatch.setattr(config, 'EDITOR_SAVE_BYTES_PER_MINUTE', 8) + monkeypatch.setattr(config, 'FILE_CONTROL_BYTES_PER_MINUTE', 10_000) + monkeypatch.setattr(socket_events.time, 'monotonic', lambda: 10.0) + assert socket_events._consume_editor_save_budget( + 7, 8, now=10.0 + ) is True + monkeypatch.setattr( + socket_events, + '_editor_retry_fingerprint', + lambda *_args, **_kwargs: pytest.fail( + 'an unknown token hashed the editor body before budget admission' + ), + ) + payload = { + 'source_id': 'sftp-session:owned', + 'request_id': 'save:unknown-challenge', + 'path': '/note.txt', + 'content': 'A' * 1024, + 'replace_strategy': 'recoverable_swap', + 'save_challenge': 'x' * 43, + } + + assert socket_events._consume_editor_retry_challenge( + payload, + 7, + 'socket-a', + ) is False + identity = socket_events._file_request_identity( + payload, + user_id=7, + allow_editor_content=True, + ) + + assert identity == { + 'source_id': None, + 'request_id': 'save:unknown-challenge', + } + + +def test_invalid_editor_metadata_still_charges_the_bounded_body(monkeypatch): + import config + + monkeypatch.setattr(config, 'MAX_EDITOR_FILE_SIZE', 16) + monkeypatch.setattr(config, 'EDITOR_SAVE_BYTES_PER_MINUTE', 16) + monkeypatch.setattr(config, 'FILE_CONTROL_BYTES_PER_MINUTE', 10_000) + monkeypatch.setattr(config, 'FILE_CONTROL_MAX_PATH_BYTES', 8) + monkeypatch.setattr(socket_events.time, 'monotonic', lambda: 10.0) + payload = { + 'source_id': 'sftp-session:owned', + 'request_id': 'save:invalid-path', + 'path': '/path-is-too-long', + 'content': 'A' * 16, + } + + identity = socket_events._file_request_identity( + payload, + user_id=7, + allow_editor_content=True, + ) + + assert identity['source_id'] is None + assert socket_events._editor_save_budgets[7] == (0.0, 10.0) + + +def test_oversized_file_control_key_is_rejected_without_utf8_copy( + monkeypatch, +): + import config + + monkeypatch.setattr(config, 'FILE_CONTROL_BYTES_PER_MINUTE', 100) + + assert socket_events._file_control_payload_cost({ + 'k' * 10_000: 'value', + }) == 101 + + +def test_cancel_transfer_rejects_oversized_identifier_before_lookup( + monkeypatch, +): + calls = [] + monkeypatch.setattr( + socket_events.transfer_manager, + 'cancel_with_result', + lambda transfer_id, user_id: calls.append((transfer_id, user_id)), + ) + + user = SimpleNamespace(id=17) + result = socket_events.handle_cancel_transfer.__wrapped__( + {'transfer_id': 'x' * 129}, + current_user=user, + ) + + assert result == {'success': False, 'state': 'unavailable'} + assert calls == [] + + +def test_file_source_disconnect_does_not_reflect_oversized_identifier( + monkeypatch, +): + emitted, user = capture(monkeypatch) + calls = [] + monkeypatch.setattr( + socket_events.connection_pool.temp_connection_pool, + 'request_close', + lambda *args: calls.append(args), + ) + + socket_events.handle_file_source_disconnect.__wrapped__( + {'source_id': 'sftp-session:' + ('x' * 200)}, + current_user=user, + ) + + assert calls == [] + assert emitted == [('error', { + 'error': 'File source unavailable', + 'code': 'SOURCE_UNAVAILABLE', + 'source_id': None, + })] + + +def test_unknown_file_control_metadata_is_charged_and_not_reflected( + monkeypatch, +): + import config + + emitted, user = capture(monkeypatch) + socket_events._file_control_budgets.clear() + monkeypatch.setattr(config, 'FILE_CONTROL_BYTES_PER_MINUTE', 100) + + class RejectBackendCall: + def list_directory_page(self, *_args, **_kwargs): + raise AssertionError('oversized metadata reached the backend') + + monkeypatch.setattr(socket_events, 'file_service', RejectBackendCall()) + socket_events.handle_list_directory.__wrapped__({ + 'source_id': 'sftp-session:owned', + 'request_id': 'list:1', + 'remote_path': '/', + 'content': 'A' * 101, + }, current_user=user) + + assert emitted == [('error', { + 'error': 'Source ID and request ID required', + 'operation': 'list_directory', + 'source_id': None, + 'request_id': 'list:1', + 'path': '/', + })] + assert 'content' not in repr(emitted) + + +@pytest.mark.parametrize( + 'content', + ( + pytest.param('A' * 17, id='ascii-character-overflow'), + pytest.param('\U0001f600' * 5, id='multibyte-byte-overflow'), + ), +) +def test_editor_content_byte_overflow_is_rejected_before_full_encode_or_backend( + app, + monkeypatch, + content, +): + import config + + class NoFullEncode(str): + def encode(self, *_args, **_kwargs): + raise AssertionError('oversized editor body reached full encode') + + emitted, user = capture(monkeypatch) + socket_events._file_control_budgets.clear() + monkeypatch.setattr(config, 'MAX_EDITOR_FILE_SIZE', 16) + monkeypatch.setattr(config, 'FILE_CONTROL_BYTES_PER_MINUTE', 1024) + monkeypatch.setattr( + socket_events, + 'file_service', + SimpleNamespace( + resolve=lambda *_args, **_kwargs: pytest.fail( + 'oversized editor body reached backend resolution' + ) + ), + ) + + with app.test_request_context('/socket.io'): + socket_events.handle_save_file.__wrapped__({ + 'source_id': 'sftp-session:owned', + 'request_id': 'save:oversized', + 'path': '/note.txt', + 'content': NoFullEncode(content), + }, current_user=user) + + assert emitted == [('error', { + 'error': 'Missing required fields for save', + 'operation': 'save_file', + 'source_id': None, + 'request_id': 'save:oversized', + 'path': '/note.txt', + })] + assert socket_events._file_control_budgets[user.id][0] == 0.0 + + def test_list_directory_accepts_source_id_and_uses_file_service(monkeypatch): emitted, user = capture(monkeypatch) backend = ListingBackend() @@ -77,6 +521,8 @@ def test_list_directory_accepts_source_id_and_uses_file_service(monkeypatch): 'path': '/srv/current', 'files': [{'name': 'config.yml'}], 'request_id': 'left:directory:4', + 'cursor': 0, + 'next_cursor': None, })] @@ -396,7 +842,11 @@ def write_file_text( 'target_host': 'host.test', 'share': 'Share', } - assert emitted[0] == ('error', { + event, failure = emitted[0] + challenge = failure.pop('save_challenge') + assert event == 'error' + assert len(challenge) >= 32 + assert failure == { 'error': 'This SMB account cannot replace the file atomically.', 'code': 'SMB_RECOVERABLE_REPLACE_REQUIRED', 'revision': 'a' * 64, @@ -404,7 +854,106 @@ def write_file_text( 'source_id': 'smb-quick:owned', 'request_id': 'save:smb:1', 'path': '/note.txt', - }) + } + + +def test_smb_recoverable_retry_reuses_one_exact_editor_body_budget( + app, + monkeypatch, +): + import config + + emitted, user = capture(monkeypatch) + monkeypatch.setattr( + socket_events, + 'log_file_source_operation', + lambda **_details: None, + ) + monkeypatch.setattr(config, 'MAX_EDITOR_FILE_SIZE', 8) + monkeypatch.setattr(config, 'EDITOR_SAVE_BYTES_PER_MINUTE', 8) + monkeypatch.setattr(config, 'FILE_CONTROL_BYTES_PER_MINUTE', 10_000) + monkeypatch.setattr(socket_events.time, 'monotonic', lambda: 10.0) + + class RecoverableBackend(OperationBackend): + def write_file_text( + self, + source, + path, + content, + *, + encoding, + newline, + allow_non_atomic=False, + expected_revision=None, + replace_strategy='atomic', + ): + self.calls.append((replace_strategy, content)) + if replace_strategy == 'atomic': + return FileWriteOutcome( + success=False, + error='Recoverable replacement consent is required.', + code='SMB_RECOVERABLE_REPLACE_REQUIRED', + revision=expected_revision, + ) + return FileWriteOutcome( + success=True, + revision='b' * 64, + ) + + backend = RecoverableBackend() + source = make_source( + 'smb-quick:owned', + tuple(FileCapability), + backend, + kind='smb', + ) + monkeypatch.setattr( + socket_events, + 'file_service', + FileService(SimpleNamespace(resolve=lambda *_args: source)), + ) + common = { + 'source_id': 'smb-quick:owned', + 'path': '/note.txt', + 'content': '12345678', + 'encoding': 'utf-8', + 'newline': 'lf', + 'expected_revision': 'a' * 64, + } + + with app.test_request_context('/socket.io'): + socket_events.handle_save_file.__wrapped__({ + **common, + 'request_id': 'save:smb:first', + 'replace_strategy': 'atomic', + }, current_user=user) + challenge = emitted[-1][1]['save_challenge'] + socket_events.handle_save_file.__wrapped__({ + **common, + 'request_id': 'save:smb:retry', + 'replace_strategy': 'recoverable_swap', + 'save_challenge': challenge, + }, current_user=user) + socket_events.handle_save_file.__wrapped__({ + **common, + 'request_id': 'save:smb:replay', + 'replace_strategy': 'recoverable_swap', + 'save_challenge': challenge, + }, current_user=user) + + assert backend.calls == [ + ('atomic', '12345678'), + ('recoverable_swap', '12345678'), + ] + assert [event for event, _payload in emitted] == [ + 'error', + 'file_saved', + 'error', + ] + assert emitted[1][1]['revision'] == 'b' * 64 + assert emitted[2][1]['source_id'] is None + assert socket_events._editor_save_budgets[user.id] == (0.0, 10.0) + assert socket_events._editor_retry_challenges == {} def test_smb_editor_surfaces_recovery_artifacts_without_raw_paths(app, monkeypatch): diff --git a/tests/test_github_auth_routes.py b/tests/test_github_auth_routes.py index 3929198e..91b5d5fe 100644 --- a/tests/test_github_auth_routes.py +++ b/tests/test_github_auth_routes.py @@ -1,5 +1,8 @@ """GitHub login, linking, provisioning, and admin API tests.""" +import base64 +import re +from types import SimpleNamespace from urllib.parse import parse_qs, urlsplit from tests.step_up_helpers import ( @@ -274,7 +277,7 @@ def test_org_rejection_fails_closed(app, client, monkeypatch): assert response.get_json()['error'] == 'GitHub organization membership is required' -def test_github_step_up_start_is_rate_limited_per_user_and_ip( +def test_github_step_up_start_rejects_ambient_provider_session( app, client, monkeypatch, ): from app.models import GitHubIdentity, GitHubOAuthState, db @@ -295,16 +298,19 @@ def test_github_step_up_start_is_rate_limited_per_user_and_ip( intent_response = client.post('/api/account/step-up/intents', json={ 'action': 'github.unlink', 'target': user_id, }) - intent = intent_response.get_json()['intent'] + assert intent_response.status_code == 403 + assert intent_response.get_json()['code'] == 'step_up_failed' - responses = [client.post('/api/account/step-up/github/start', json={ - 'intent': intent, 'continuation': '/security', - }) for _ in range(6)] + response = client.post('/api/account/step-up/github/start', json={ + 'intent': 'untrusted', 'continuation': '/security', + }) - assert [response.status_code for response in responses] == [200] * 5 + [429] - assert responses[-1].headers['Retry-After'] == '60' + assert response.status_code == 403 + assert response.get_json() == { + 'error': 'Step-up authentication failed' + } with app.app_context(): - assert GitHubOAuthState.query.count() == 5 + assert GitHubOAuthState.query.count() == 0 def test_github_provisioned_account_cannot_fall_back_to_local_password(app): @@ -356,3 +362,135 @@ def test_unlink_refuses_to_remove_managed_accounts_only_primary( assert response.status_code == 409 assert 'passkey' in response.get_json()['error'].lower() + + +def test_operator_code_bootstraps_first_passkey_then_allows_github_unlink( + app, + client, + monkeypatch, +): + import config + import app.account_step_up_routes as account_routes + import app.webauthn_routes as webauthn_routes + from app.models import ( + FactorBootstrapToken, + GitHubIdentity, + WebAuthnCredential, + db, + ) + + admin_id = _create_user(app, 'bootstrap_break_glass', is_admin=True) + _configure(app, admin_id, auto_provision=True) + monkeypatch.setattr(config, 'WEBAUTHN_ENABLED', True) + monkeypatch.setattr(config, 'WEBAUTHN_RP_ID', 'localhost') + monkeypatch.setattr(config, 'WEBAUTHN_RP_NAME', 'WebSSH Test') + monkeypatch.setattr(config, 'WEBAUTHN_ORIGIN', 'https://localhost') + app.extensions['security_feature_readiness']['passkey'] = (True, None) + state = _begin_login(client) + _provider(monkeypatch, user_id='9292', login='bootstrap-managed') + assert client.get( + f'/auth/github/callback?code=code&state={state}' + ).status_code == 302 + with app.app_context(): + identity = GitHubIdentity.query.filter_by( + github_user_id='9292' + ).one() + user_id = identity.user_id + username = identity.user.username + + issued = app.test_cli_runner().invoke(args=[ + 'issue-factor-bootstrap', + '--username', username, + '--action', 'passkey.enroll', + ]) + assert issued.exit_code == 0, issued.output + match = re.search(r'^Enrollment code: (\S+)$', issued.output, re.MULTILINE) + assert match is not None + enrollment_code = match.group(1) + with app.app_context(): + row = FactorBootstrapToken.query.one() + assert enrollment_code not in row.token_hash + assert row.action == 'passkey.enroll' + + started = client.post('/api/account/step-up/intents', json={ + 'action': 'passkey.enroll', + 'target': user_id, + }) + assert started.status_code == 200 + intent = started.get_json()['intent'] + assert started.get_json()['methods'] == ['bootstrap'] + rejected = client.post('/api/account/step-up/bootstrap', json={ + 'intent': intent, + 'code': 'x' * 43, + }) + assert rejected.status_code == 403 + redeemed = client.post('/api/account/step-up/bootstrap', json={ + 'intent': intent, + 'code': enrollment_code, + }) + assert redeemed.status_code == 200 + assert redeemed.get_json()['method'] == 'bootstrap' + repeated = client.post('/api/account/step-up/bootstrap', json={ + 'intent': intent, + 'code': enrollment_code, + }) + assert repeated.status_code == 403 + + options = client.post( + '/api/webauthn/register/options', + json={}, + headers={ + 'X-WebSSH-Step-Up': redeemed.get_json()['grant'], + }, + ) + assert options.status_code == 200 + monkeypatch.setattr( + webauthn_routes, + 'verify_registration_response', + lambda **_kwargs: SimpleNamespace( + credential_id=b'bootstrap-passkey', + credential_public_key=b'public-key', + sign_count=0, + ), + ) + registered = client.post('/api/webauthn/register/verify', json={ + 'ceremony': options.get_json()['ceremony'], + 'credential': {'id': 'browser-credential', 'response': {}}, + 'name': 'Bootstrap passkey', + }) + assert registered.status_code == 201 + + unlink_intent = client.post('/api/account/step-up/intents', json={ + 'action': 'github.unlink', + 'target': user_id, + }) + assert unlink_intent.status_code == 200 + assert unlink_intent.get_json()['methods'] == ['passkey'] + unlink_token = unlink_intent.get_json()['intent'] + passkey_options = client.post( + '/api/account/step-up/passkey/options', + json={'intent': unlink_token}, + ) + assert passkey_options.status_code == 200 + monkeypatch.setattr( + account_routes, + 'verify_authentication_response', + lambda **kwargs: SimpleNamespace( + new_sign_count=kwargs['credential_current_sign_count'] + 1 + ), + ) + encoded_id = base64.urlsafe_b64encode( + b'bootstrap-passkey' + ).decode().rstrip('=') + confirmed = client.post('/api/account/step-up/passkey/verify', json={ + 'intent': unlink_token, + 'credential': {'id': encoded_id}, + }) + assert confirmed.status_code == 200 + unlinked = client.delete('/api/account/github', headers={ + 'X-WebSSH-Step-Up': confirmed.get_json()['grant'], + }) + assert unlinked.status_code == 200 + with app.app_context(): + assert GitHubIdentity.query.filter_by(user_id=user_id).first() is None + assert WebAuthnCredential.query.filter_by(user_id=user_id).count() == 1 diff --git a/tests/test_gunicorn_command.py b/tests/test_gunicorn_command.py index a9702a5f..9a3aa87c 100644 --- a/tests/test_gunicorn_command.py +++ b/tests/test_gunicorn_command.py @@ -25,6 +25,15 @@ def test_native_runtime_keeps_the_reviewed_gunicorn_and_worker_contract(): ] +def test_image_prepares_private_recovery_mountpoint_before_dropping_root(): + dockerfile = Path("Dockerfile").read_text(encoding="utf-8") + setup = dockerfile.split("COPY . /app", 1)[1].split("USER appuser", 1)[0] + + assert "mkdir -p /app/data/logs /app/data/keys /app/recovery" in setup + assert "chown appuser:appuser /app/recovery" in setup + assert "chmod 700 /app/recovery" in setup + + def test_container_ci_run_is_labeled_with_the_workflow_attempt_identity(): workflow = container_smoke_workflow() @@ -42,7 +51,10 @@ def test_container_ci_image_is_bound_to_the_tested_revision(): def test_container_ci_start_failure_trap_removes_only_its_labeled_container(): workflow = container_smoke_workflow() - start_step = workflow.split(" - name: Verify gthread worker and readiness", 1)[0] + start_step = workflow.split( + " - name: Verify recovery volume, gthread worker and readiness", + 1, + )[0] assert "trap cleanup_start_failure EXIT" in start_step assert 'if [ "$status" -ne 0 ] && docker container inspect "$container_name"' in start_step @@ -51,13 +63,59 @@ def test_container_ci_start_failure_trap_removes_only_its_labeled_container(): assert "trap - EXIT" in start_step +def test_container_ci_uses_a_fresh_labeled_recovery_volume(): + workflow = container_smoke_workflow() + start_step = workflow.split( + " - name: Verify recovery volume, gthread worker and readiness", + 1, + )[0] + + assert 'recovery_volume="webssh-ci-recovery-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"' in start_step + assert 'if docker volume inspect "$recovery_volume"' in start_step + assert 'docker volume create \\\n --label "$ownership_label"' in start_step + assert '--mount type=volume,source="$recovery_volume",target=/app/recovery' in start_step + assert '--env BACKUP_TEMP_DIR=/app/recovery' in start_step + assert '--env BACKUP_RECOVERY_DURABLE=true' in start_step + + +def test_container_ci_verifies_recovery_volume_contract_and_readiness(): + workflow = container_smoke_workflow() + verify_step = workflow.split( + " - name: Verify recovery volume, gthread worker and readiness", + 1, + )[1].split(" - name: Verify graceful gthread shutdown", 1)[0] + + assert 'mounted_volume="$(docker inspect --format' in verify_step + assert 'stat --format=%u:%g:%a /app/recovery' in verify_step + assert 'expected_owner="$(id -u):$(id -g):700"' in verify_step + assert 'test -w /app/recovery' in verify_step + assert '.webssh-ci-write-probe' in verify_step + assert '[ "$ready_status" = "200" ]' in verify_step + + def test_container_ci_normal_cleanup_rechecks_ownership_before_removal(): workflow = container_smoke_workflow() cleanup_step = workflow.split(" - name: Clean up created container", 1)[1] assert 'docker container inspect "$WEBSSH_CI_CONTAINER"' in cleanup_step assert 'if [ "$actual_label" != "$WEBSSH_CI_CONTAINER_LABEL_VALUE" ]; then' in cleanup_step - assert 'docker rm --force "$WEBSSH_CI_CONTAINER"' in cleanup_step + assert 'docker rm --force --volumes "$WEBSSH_CI_CONTAINER"' in cleanup_step + assert 'docker volume inspect "$WEBSSH_CI_RECOVERY_VOLUME"' in cleanup_step + assert 'docker volume inspect --format' in cleanup_step + assert 'docker volume rm "$WEBSSH_CI_RECOVERY_VOLUME"' in cleanup_step + + +def test_container_ci_start_failure_cleans_only_its_labeled_volume(): + workflow = container_smoke_workflow() + start_step = workflow.split( + " - name: Verify recovery volume, gthread worker and readiness", + 1, + )[0] + + assert '[ "$recovery_volume_created" = "1" ]' in start_step + assert 'docker volume inspect --format' in start_step + assert 'if [ "$actual_label" = "$ownership_value" ]; then' in start_step + assert 'docker volume rm "$recovery_volume" || true' in start_step def test_container_ci_readiness_accepts_only_http_200(): diff --git a/tests/test_gunicorn_socket_capacity.py b/tests/test_gunicorn_socket_capacity.py index 53af034a..54d67b0f 100644 --- a/tests/test_gunicorn_socket_capacity.py +++ b/tests/test_gunicorn_socket_capacity.py @@ -8,6 +8,8 @@ import pytest +from app.socket_protocol import SOCKET_WIRE_REVISION + pytestmark = pytest.mark.skipif( os.name == 'nt', @@ -137,6 +139,7 @@ def test_socket_limit_keeps_ready_endpoint_responsive(tmp_path): ) client.connect( base_url, + auth={'wire_revision': SOCKET_WIRE_REVISION}, transports=['polling'], wait_timeout=5, ) @@ -152,6 +155,7 @@ def test_socket_limit_keeps_ready_endpoint_responsive(tmp_path): with pytest.raises(socketio.exceptions.ConnectionError): rejected.connect( base_url, + auth={'wire_revision': SOCKET_WIRE_REVISION}, transports=['polling'], wait_timeout=5, ) diff --git a/tests/test_i18n_parity.py b/tests/test_i18n_parity.py index a2576e56..f3cbf2ac 100644 --- a/tests/test_i18n_parity.py +++ b/tests/test_i18n_parity.py @@ -363,6 +363,8 @@ def test_connection_state_messages_use_the_active_locale(): 'connection.lostReconnecting', 'connection.lostReconnectingAttempt', 'connection.disconnectedFromServer', + 'connection.reloadRequired', + 'connection.reloadPage', ): assert f"i18n.t('{key}')" in source diff --git a/tests/test_jump_host_manager.py b/tests/test_jump_host_manager.py index fb303cea..1c50aa45 100644 --- a/tests/test_jump_host_manager.py +++ b/tests/test_jump_host_manager.py @@ -1,5 +1,6 @@ """Corruption handling for per-user jump-host storage.""" +from contextlib import contextmanager import threading import pytest @@ -76,6 +77,41 @@ def test_referenced_jump_host_cannot_be_deleted_and_reports_safe_profile_names( assert jump_host_manager.load_jump_hosts(user_id) == [jump_host] +def test_jump_host_reference_details_are_bounded_without_losing_total( + app, + monkeypatch, +): + from app import jump_host_manager + + user_id = _create_user(app) + detail_limit = jump_host_manager._JUMP_HOST_USAGE_DETAIL_LIMIT + profiles = [ + { + 'id': f'profile-{index}', + 'name': f'Profile {index}', + 'jump_host_id': 'shared-jump-host', + } + for index in range(detail_limit + 7) + ] + monkeypatch.setattr( + jump_host_manager, + '_load_profile_references', + lambda _user_id: profiles, + ) + with app.app_context(): + success, error, usages = jump_host_manager.delete_jump_host( + user_id, + 'shared-jump-host', + ) + + assert success is False + assert error == ( + f'Jump host is used by {len(profiles)} profiles ' + f'(showing first {detail_limit})' + ) + assert usages == [f'Profile {index}' for index in range(detail_limit)] + + def test_missing_jump_host_delete_is_not_found(app): from app import jump_host_manager @@ -86,6 +122,90 @@ def test_missing_jump_host_delete_is_not_found(app): ) == (False, 'Jump host not found', []) +def test_normal_jump_host_delete_removes_only_first_duplicate_id(app): + from app import jump_host_manager + + user_id = _create_user(app) + jump_hosts = [ + { + 'id': 'duplicate-id', + 'name': name, + 'host': f'{name.casefold()}.example', + 'port': 22, + 'username': 'deploy', + 'auth_type': 'password', + } + for name in ('First', 'Second') + ] + with app.app_context(): + assert jump_host_manager.save_jump_hosts(user_id, jump_hosts) + + assert jump_host_manager.delete_jump_host( + user_id, + 'duplicate-id', + ) == (True, None, []) + + assert [ + item['name'] for item in jump_host_manager.load_jump_hosts(user_id) + ] == ['Second'] + + +def test_normal_jump_host_delete_from_compact_limit_store_stays_readable( + app, + monkeypatch, +): + import json + import config + from app import jump_host_manager + from app.storage_migrations import CURRENT_STORAGE_VERSIONS + + user_id = _create_user(app) + jump_hosts = [ + { + 'id': str(index), + 'name': 'x', + 'host': 'b.example', + 'port': 22, + 'username': 'u', + 'auth_type': 'password', + } + for index in range(30) + ] + document = { + 'schema_version': CURRENT_STORAGE_VERSIONS['jump_hosts'], + 'jump_hosts': jump_hosts, + } + original = json.dumps(document, separators=(',', ':')).encode('utf-8') + assert len(original) == 2725 + + with app.app_context(): + path = jump_host_manager._get_file(user_id) + path.write_bytes(original) + monkeypatch.setattr( + config, + 'CONNECTION_STORE_MAX_BYTES', + len(original), + ) + monkeypatch.setattr( + config, + 'CONNECTION_CONFIG_MAX_BYTES', + len(original), + ) + + assert jump_host_manager.delete_jump_host( + user_id, + jump_hosts[-1]['id'], + ) == (True, None, []) + + expected = json.dumps({ + **document, + 'jump_hosts': jump_hosts[:-1], + }, separators=(',', ':')).encode('utf-8') + assert path.read_bytes() == expected + assert len(expected) < len(original) + assert jump_host_manager.load_jump_hosts(user_id) == jump_hosts[:-1] + + def test_jump_host_delete_and_stale_profile_edit_are_serialized(app, monkeypatch): from app import jump_host_manager, profile_manager @@ -147,3 +267,461 @@ def save_stale_profile(): assert writer.is_alive() is False assert delete_result['value'] == (True, None, []) assert profile_result['value'] == (None, 'Jump host not found') + + +def test_jump_host_count_quota_is_atomic_and_delete_remains_available( + app, + monkeypatch, +): + import config + from app import jump_host_manager + + user_id = _create_user(app) + monkeypatch.setattr(config, 'JUMP_HOST_MAX_RECORDS', 1) + with app.app_context(): + first, error = jump_host_manager.add_jump_host( + user_id, 'First', 'first.example', 22, 'deploy', 'password' + ) + assert error is None + + second, error = jump_host_manager.add_jump_host( + user_id, 'Second', 'second.example', 22, 'deploy', 'password' + ) + + assert second is None + assert error.startswith('Connection storage quota exceeded:') + assert jump_host_manager.load_jump_hosts(user_id) == [first] + assert jump_host_manager.delete_jump_host( + user_id, first['id'] + ) == (True, None, []) + + +def test_jump_host_cannot_reference_another_users_ssh_key( + app, + rsa_private_key_pem, +): + from app import jump_host_manager, key_manager + from app.models import User, db + + attacker_id = _create_user(app) + with app.app_context(): + owner = User(username='jump-key-owner', password_hash='unused') + db.session.add(owner) + db.session.commit() + key, error = key_manager.save_key( + owner.id, 'Owner key', rsa_private_key_pem + ) + assert error is None + + jump_host, error = jump_host_manager.add_jump_host( + attacker_id, + 'Foreign key', + 'bastion.example', + 22, + 'deploy', + 'key', + key['id'], + ) + + assert jump_host is None + assert error == 'SSH key not found' + + +def test_jump_host_create_uses_cross_store_coordinator_before_store_lock( + app, + monkeypatch, +): + from app import jump_host_manager + from app.storage_utils import storage_lock as real_storage_lock + + user_id = _create_user(app) + requested = [] + + @contextmanager + def instrumented_storage_lock(key): + requested.append(key) + with real_storage_lock(key): + yield + + monkeypatch.setattr( + jump_host_manager, + 'storage_lock', + instrumented_storage_lock, + ) + + with app.app_context(): + jump_host, error = jump_host_manager.add_jump_host( + user_id, + 'Bastion', + 'bastion.example', + 22, + 'deploy', + 'password', + ) + + assert error is None + assert jump_host is not None + assert requested == [ + f'command-config:{user_id}', + f'jump_hosts:{user_id}', + ] + + +def test_legacy_oversized_jump_host_store_has_bounded_offline_recovery( + app, + monkeypatch, +): + import json + import config + from app import jump_host_manager + from app.connection_storage_policy import ConnectionStorageLimitError + from app.storage_migrations import CURRENT_STORAGE_VERSIONS + + user_id = _create_user(app) + jump_host = { + 'id': 'legacy-large', + 'name': 'Legacy', + 'host': 'bastion.example', + 'port': 22, + 'username': 'deploy', + 'auth_type': 'password', + 'future': 'x' * 1024, + } + with app.app_context(): + path = jump_host_manager._get_file(user_id) + path.write_text(json.dumps({ + 'schema_version': CURRENT_STORAGE_VERSIONS['jump_hosts'], + 'jump_hosts': [jump_host], + }), encoding='utf-8') + monkeypatch.setattr(config, 'CONNECTION_STORE_MAX_BYTES', 256) + + with pytest.raises(ConnectionStorageLimitError): + jump_host_manager.load_jump_hosts(user_id) + with pytest.raises(ConnectionStorageLimitError): + jump_host_manager.get_jump_host(user_id, jump_host['id']) + summaries = jump_host_manager.load_jump_host_recovery_summaries(user_id) + assert summaries == [{ + 'id': 'legacy-large', + 'name': 'Legacy', + 'host': 'bastion.example', + 'selector': summaries[0]['selector'], + }] + added, error = jump_host_manager.add_jump_host( + user_id, 'New', 'new.example', 22, 'deploy', 'password' + ) + assert added is None + assert error == ( + 'Connection storage quota exceeded: stored data exceeds its byte limit' + ) + assert jump_host_manager.delete_jump_host( + user_id, jump_host['id'] + ) == ( + False, + 'Connection storage quota exceeded: stored data exceeds its byte limit', + [], + ) + assert jump_host_manager.delete_jump_host_recovery_record( + user_id, summaries[0]['selector'] + ) == (True, None, []) + assert jump_host_manager.load_jump_hosts(user_id) == [] + + +def test_jump_host_recovery_delete_writes_compact_shrink(app, monkeypatch): + import json + import config + from app import jump_host_manager + from app.storage_migrations import CURRENT_STORAGE_VERSIONS + + user_id = _create_user(app) + jump_hosts = [ + { + 'id': str(index), + 'name': 'x', + 'host': 'b.example', + 'port': 22, + 'username': 'u', + 'auth_type': 'password', + } + for index in range(50) + ] + document = { + 'schema_version': CURRENT_STORAGE_VERSIONS['jump_hosts'], + 'jump_hosts': jump_hosts, + } + original = json.dumps(document, separators=(',', ':')).encode('utf-8') + with app.app_context(): + path = jump_host_manager._get_file(user_id) + path.write_bytes(original) + monkeypatch.setattr(config, 'CONNECTION_STORE_MAX_BYTES', 64) + monkeypatch.setattr(config, 'CONNECTION_CONFIG_MAX_BYTES', 64) + monkeypatch.setattr( + config, + 'CONNECTION_STORE_RECOVERY_MAX_BYTES', + len(original), + ) + + summaries = jump_host_manager.load_jump_host_recovery_summaries( + user_id + ) + assert jump_host_manager.delete_jump_host_recovery_record( + user_id, + summaries[-1]['selector'], + ) == (True, None, []) + + expected = json.dumps({ + 'schema_version': CURRENT_STORAGE_VERSIONS['jump_hosts'], + 'jump_hosts': jump_hosts[:-1], + }, separators=(',', ':')).encode('utf-8') + assert path.read_bytes() == expected + assert len(expected) < len(original) + + +def test_oversized_profile_store_requires_offline_jump_host_recovery( + app, + monkeypatch, +): + import json + import config + from app import jump_host_manager, profile_manager + from app.storage_migrations import CURRENT_STORAGE_VERSIONS + + user_id = _create_user(app) + with app.app_context(): + referenced, error = jump_host_manager.add_jump_host( + user_id, + 'Referenced', + 'referenced.example', + 22, + 'deploy', + 'password', + ) + assert error is None + unused, error = jump_host_manager.add_jump_host( + user_id, + 'Unused', + 'unused.example', + 22, + 'deploy', + 'password', + ) + assert error is None + path = profile_manager.get_user_profiles_file(user_id) + path.write_text(json.dumps({ + 'schema_version': CURRENT_STORAGE_VERSIONS['profiles'], + 'profiles': [{ + 'id': 'legacy-large', + 'name': 'Production', + 'jump_host_id': referenced['id'], + 'future': 'x' * 1024, + }], + }), encoding='utf-8') + monkeypatch.setattr(config, 'CONNECTION_STORE_MAX_BYTES', 256) + monkeypatch.setattr( + config, + 'CONNECTION_STORE_RECOVERY_MAX_BYTES', + 4096, + ) + + normal_limit_error = ( + 'Connection storage quota exceeded: stored data exceeds its byte limit' + ) + assert jump_host_manager.delete_jump_host(user_id, unused['id']) == ( + False, + normal_limit_error, + [], + ) + assert jump_host_manager.delete_jump_host(user_id, referenced['id']) == ( + False, + normal_limit_error, + [], + ) + + summaries = jump_host_manager.load_jump_host_recovery_summaries(user_id) + unused_selector = next( + item['selector'] for item in summaries if item['id'] == unused['id'] + ) + referenced_selector = next( + item['selector'] + for item in summaries + if item['id'] == referenced['id'] + ) + assert jump_host_manager.delete_jump_host_recovery_record( + user_id, + unused_selector, + ) == (True, None, []) + assert jump_host_manager.delete_jump_host_recovery_record( + user_id, + unused_selector, + ) == ( + False, + 'Recovery selector not found; list the store again.', + [], + ) + assert jump_host_manager.delete_jump_host_recovery_record( + user_id, + referenced_selector, + ) == (False, 'Jump host is used by 1 profile', ['Production']) + + +def test_jump_host_recovery_ceiling_rejects_before_json_load( + app, + monkeypatch, +): + import config + from app import jump_host_manager + + user_id = _create_user(app) + with app.app_context(): + path = jump_host_manager._get_file(user_id) + path.write_bytes(b'x' * 257) + monkeypatch.setattr(config, 'CONNECTION_STORE_RECOVERY_MAX_BYTES', 256) + monkeypatch.setattr( + jump_host_manager, + 'load_json_migrated', + lambda *_args, **_kwargs: pytest.fail( + 'oversized recovery store was parsed' + ), + ) + + success, error, usages = ( + jump_host_manager.delete_jump_host_recovery_record( + user_id, + 'r1:0:' + ('0' * 64), + ) + ) + + assert success is False + assert error == ( + 'Connection storage quota exceeded: stored data exceeds its recovery ' + 'byte limit' + ) + assert usages == [] + + +def test_jump_host_recovery_record_ceiling_rejects_after_bounded_load( + app, + monkeypatch, +): + import json + import config + from app import jump_host_manager, storage_migrations + + user_id = _create_user(app) + with app.app_context(): + target, error = jump_host_manager.add_jump_host( + user_id, 'Target', 'target.example', 22, 'deploy', 'password' + ) + assert error is None + other, error = jump_host_manager.add_jump_host( + user_id, 'Other', 'other.example', 22, 'deploy', 'password' + ) + assert error is None + path = jump_host_manager._get_file(user_id) + document = json.loads(path.read_text(encoding='utf-8')) + document['schema_version'] = 1 + original = json.dumps(document, separators=(',', ':')).encode('utf-8') + path.write_bytes(original) + monkeypatch.setattr( + config, + 'CONNECTION_STORE_RECOVERY_MAX_RECORDS', + 1, + ) + migrate_document = storage_migrations.migrate_document + + def reject_jump_host_migration(store_name, candidate): + if store_name == 'jump_hosts': + pytest.fail('over-record store was migrated') + return migrate_document(store_name, candidate) + + monkeypatch.setattr( + storage_migrations, + 'migrate_document', + reject_jump_host_migration, + ) + + success, error, usages = ( + jump_host_manager.delete_jump_host_recovery_record( + user_id, + 'r1:0:' + ('0' * 64), + ) + ) + + assert path.read_bytes() == original + assert list(path.parent.glob('jump_hosts.json.*.bak')) == [] + assert other['id'] != target['id'] + + assert success is False + assert error == ( + 'Connection storage quota exceeded: more than 1 recovery records ' + 'are not allowed' + ) + assert usages == [] + + +def test_jump_host_recovery_delete_persists_valid_legacy_shrink(app): + import json + from app import jump_host_manager + from app.storage_migrations import CURRENT_STORAGE_VERSIONS + + user_id = _create_user(app) + with app.app_context(): + target, error = jump_host_manager.add_jump_host( + user_id, 'Target', 'target.example', 22, 'deploy', 'password' + ) + assert error is None + other, error = jump_host_manager.add_jump_host( + user_id, 'Other', 'other.example', 22, 'deploy', 'password' + ) + assert error is None + path = jump_host_manager._get_file(user_id) + document = json.loads(path.read_text(encoding='utf-8')) + document['schema_version'] = 1 + path.write_text(json.dumps(document), encoding='utf-8') + + summaries = jump_host_manager.load_jump_host_recovery_summaries(user_id) + selector = next( + item['selector'] for item in summaries if item['id'] == target['id'] + ) + assert jump_host_manager.delete_jump_host_recovery_record( + user_id, + selector, + ) == (True, None, []) + + persisted = json.loads(path.read_text(encoding='utf-8')) + assert persisted['schema_version'] == CURRENT_STORAGE_VERSIONS['jump_hosts'] + assert [item['id'] for item in persisted['jump_hosts']] == [other['id']] + + +def test_jump_host_recovery_selector_deletes_only_selected_duplicate(app): + import json + from app import jump_host_manager + from app.storage_migrations import CURRENT_STORAGE_VERSIONS + + user_id = _create_user(app) + jump_hosts = [ + { + 'id': 'duplicate-id', + 'name': name, + 'host': f'{name.casefold()}.example', + 'port': 22, + 'username': 'deploy', + 'auth_type': 'password', + } + for name in ('First', 'Second') + ] + with app.app_context(): + path = jump_host_manager._get_file(user_id) + path.write_text(json.dumps({ + 'schema_version': CURRENT_STORAGE_VERSIONS['jump_hosts'], + 'jump_hosts': jump_hosts, + }), encoding='utf-8') + summaries = jump_host_manager.load_jump_host_recovery_summaries(user_id) + + assert summaries[0]['selector'] != summaries[1]['selector'] + assert jump_host_manager.delete_jump_host_recovery_record( + user_id, + summaries[1]['selector'], + ) == (True, None, []) + + persisted = json.loads(path.read_text(encoding='utf-8'))['jump_hosts'] + assert [item['name'] for item in persisted] == ['First'] diff --git a/tests/test_ldap_auth.py b/tests/test_ldap_auth.py index 9eb7e412..b65e815e 100644 --- a/tests/test_ldap_auth.py +++ b/tests/test_ldap_auth.py @@ -1,6 +1,7 @@ """Security boundaries for optional LDAP authentication.""" import sqlite3 +import time from dataclasses import dataclass from threading import Event, Thread from urllib.parse import urlsplit @@ -1125,9 +1126,19 @@ def test_linked_user_cannot_be_promoted_to_admin(app, client): def test_disabling_ldap_invalidates_existing_linked_browser_session(app, client): - from app.models import LDAPIdentity, db + from app.models import AuthenticationSession, LDAPIdentity, User, db user_id = _create_user(app, "alice") + other_client = app.test_client() + assert client.post( + '/login', + data={'username': 'alice', 'password': 'password123'}, + ).status_code == 302 + session_cookie_name = app.config['SESSION_COOKIE_NAME'] + other_client.set_cookie( + session_cookie_name, + client.get_cookie(session_cookie_name).value, + ) with app.app_context(): db.session.add(LDAPIdentity( user_id=user_id, @@ -1137,9 +1148,9 @@ def test_disabling_ldap_invalidates_existing_linked_browser_session(app, client) distinguished_name="uid=alice,dc=example,dc=com", )) db.session.commit() - with client.session_transaction() as browser_session: - browser_session["_user_id"] = str(user_id) - browser_session["_fresh"] = True + assert AuthenticationSession.query.filter_by( + user_id=user_id, + ).count() == 1 response = client.get("/") @@ -1147,6 +1158,15 @@ def test_disabling_ldap_invalidates_existing_linked_browser_session(app, client) assert response.headers["Location"].endswith("/login") with client.session_transaction() as browser_session: assert "_user_id" not in browser_session + with app.app_context(): + assert AuthenticationSession.query.filter_by( + user_id=user_id, + ).count() == 0 + assert db.session.get(User, user_id).auth_generation == 1 + + other_response = other_client.get('/') + assert other_response.status_code == 302 + assert '/login' in other_response.headers['Location'] def test_due_ldap_session_revalidation_fails_closed( @@ -1157,9 +1177,19 @@ def test_due_ldap_session_revalidation_fails_closed( import config import app.ldap_session as ldap_session from app.ldap_service import LDAPUnavailable - from app.models import LDAPIdentity, db + from app.models import AuthenticationSession, LDAPIdentity, User, db user_id = _create_user(app, "alice") + other_client = app.test_client() + assert client.post( + '/login', + data={'username': 'alice', 'password': 'password123'}, + ).status_code == 302 + session_cookie_name = app.config['SESSION_COOKIE_NAME'] + other_client.set_cookie( + session_cookie_name, + client.get_cookie(session_cookie_name).value, + ) with app.app_context(): db.session.add(LDAPIdentity( user_id=user_id, @@ -1169,6 +1199,9 @@ def test_due_ldap_session_revalidation_fails_closed( distinguished_name="uid=alice,dc=example,dc=com", )) db.session.commit() + assert AuthenticationSession.query.filter_by( + user_id=user_id, + ).count() == 1 monkeypatch.setattr(config, "LDAP_ENABLED", True) monkeypatch.setattr( ldap_session, @@ -1176,9 +1209,9 @@ def test_due_ldap_session_revalidation_fails_closed( lambda _user: (_ for _ in ()).throw(LDAPUnavailable("offline")), ) with client.session_transaction() as browser_session: - browser_session["_user_id"] = str(user_id) - browser_session["_fresh"] = True browser_session["_ldap_verified_at"] = 0 + with other_client.session_transaction() as browser_session: + browser_session["_ldap_verified_at"] = int(time.time()) response = client.get("/") @@ -1186,18 +1219,150 @@ def test_due_ldap_session_revalidation_fails_closed( assert response.headers["Location"].endswith("/login") with client.session_transaction() as browser_session: assert "_user_id" not in browser_session + with app.app_context(): + assert AuthenticationSession.query.filter_by( + user_id=user_id, + ).count() == 0 + assert db.session.get(User, user_id).auth_generation == 1 + + other_response = other_client.get('/') + assert other_response.status_code == 302 + assert '/login' in other_response.headers['Location'] + + +def test_ldap_validation_receipts_are_non_sliding_and_identity_bound( + tmp_path, + monkeypatch, +): + import app.ldap_session as ldap_session + + monotonic = [10.0] + epoch = [1000.0] + fence = ldap_session.LDAPRevocationFence( + tmp_path / 'ldap-fence', + clock=lambda: monotonic[0], + epoch_clock=lambda: epoch[0], + ) + + class Identity: + id = 7 + provider = 'default' + subject = 'stable-subject' + directory_username = 'alice' + + class User: + id = 3 + auth_generation = 2 + is_locked = False + is_admin = False + ldap_identity = Identity() + + class Application: + extensions = {'ldap_revocation_fence': fence} + + user = User() + application = Application() + validations = [] + monkeypatch.setattr( + ldap_session, + 'revalidate_user', + lambda candidate: validations.append(candidate.id), + ) + + first = ldap_session.ensure_recent_ldap_validation( + application, + user, + max_age_seconds=5, + ) + monotonic[0] = 14.999 + epoch[0] = 1500.0 + cached = ldap_session.ensure_recent_ldap_validation( + application, + user, + max_age_seconds=5, + ) + + assert cached is first + assert cached.verified_at_epoch == 1000 + assert validations == [user.id] + + # A cache hit must not extend the validation interval. At the exact + # boundary a new directory lookup is required. + monotonic[0] = 15.0 + epoch[0] = 2000.0 + boundary = ldap_session.ensure_recent_ldap_validation( + application, + user, + max_age_seconds=5, + ) + assert boundary is not first + assert boundary.verified_at_epoch == 2000 + assert validations == [user.id, user.id] + + # A receipt is tied to the stable LDAP mapping and authentication epoch. + user.ldap_identity.subject = 'replacement-subject' + monotonic[0] = 15.1 + epoch[0] = 3000.0 + remapped = ldap_session.ensure_recent_ldap_validation( + application, + user, + max_age_seconds=5, + ) + assert remapped.identity_key != boundary.identity_key + assert validations == [user.id, user.id, user.id] + + # A scheduled sweep may reuse work completed after it began, but it must + # not let a pre-sweep foreground receipt postpone directory validation. + assert ldap_session.revalidate_user_durably( + application, + user, + not_before_monotonic=15.0, + ) is remapped + epoch[0] = 4000.0 + forced = ldap_session.revalidate_user_durably( + application, + user, + not_before_monotonic=15.1, + ) + assert forced.verified_at_epoch == 4000 + assert validations == [user.id, user.id, user.id, user.id] + assert fence.contains(user.id) is False + + +def test_stale_ldap_validation_cannot_clear_newer_revocation(tmp_path): + import app.ldap_session as ldap_session + from app.ldap_service import LDAPLookupRejected + + fence = ldap_session.LDAPRevocationFence(tmp_path / 'ldap-fence') + token = fence.begin_validation(9) + + fence.mark(9) + with pytest.raises(LDAPLookupRejected): + fence.complete_validation(9, token, ('stale-identity',)) + fence.fail_validation(9, token) + + assert fence.contains(9) is True + assert fence._marker_path(9).read_bytes() == b'pending\n' def test_background_revalidation_revokes_invalid_linked_socket_owner( app, + client, monkeypatch, ): import app.ldap_session as ldap_session from app.ldap_service import LDAPLookupRejected - from app.models import LDAPIdentity, db + from app.models import AuthenticationSession, LDAPIdentity, User, db user_id = _create_user(app, "alice") + assert client.post( + '/login', + data={'username': 'alice', 'password': 'password123'}, + ).status_code == 302 with app.app_context(): + assert AuthenticationSession.query.filter_by( + user_id=user_id, + ).count() == 1 db.session.add(LDAPIdentity( user_id=user_id, provider="default", @@ -1221,6 +1386,83 @@ def test_background_revalidation_revokes_invalid_linked_socket_owner( ldap_session.revalidate_all_linked_users(app, socketio_instance=object()) assert revoked == [user_id] + with app.app_context(): + assert AuthenticationSession.query.filter_by( + user_id=user_id, + ).count() == 0 + assert db.session.get(User, user_id).auth_generation == 1 + + +@pytest.mark.parametrize( + ('marker_failure', 'propagates'), + ((OSError('disk full'), False), (KeyboardInterrupt(), True)), +) +def test_ldap_marker_failure_still_commits_database_invalidation( + app, + client, + monkeypatch, + marker_failure, + propagates, +): + import app.ldap_session as ldap_session + from app.models import AuthenticationSession, LDAPIdentity, User, db + + user_id = _create_user(app, 'ldap_marker_failure') + assert client.post( + '/login', + data={ + 'username': 'ldap_marker_failure', + 'password': 'password123', + }, + ).status_code == 302 + with app.app_context(): + db.session.add(LDAPIdentity( + user_id=user_id, + provider='default', + subject='stable-marker-failure-id', + directory_username='ldap_marker_failure', + distinguished_name=( + 'uid=ldap_marker_failure,dc=example,dc=com' + ), + )) + db.session.commit() + assert AuthenticationSession.query.filter_by( + user_id=user_id, + ).count() == 1 + + fence = app.extensions['ldap_revocation_fence'] + monkeypatch.setattr( + fence, + '_write_marker_locked', + lambda _user_id: (_ for _ in ()).throw(marker_failure), + ) + monkeypatch.setattr( + ldap_session, + 'log_security_event', + lambda *_args, **_kwargs: None, + ) + user = db.session.get(User, user_id) + if propagates: + with pytest.raises(KeyboardInterrupt): + ldap_session.persist_ldap_authentication_invalidation( + app, + user, + ) + else: + assert ldap_session.persist_ldap_authentication_invalidation( + app, + user, + ) is None + + assert AuthenticationSession.query.filter_by( + user_id=user_id, + ).count() == 0 + assert db.session.get(User, user_id).auth_generation == 1 + marker_directory = fence._marker_directory + + assert ldap_session.LDAPRevocationFence( + marker_directory, + ).contains(user_id) is False def test_admin_can_run_redacted_ldap_readiness_probe( diff --git a/tests/test_network_policy.py b/tests/test_network_policy.py index 921130b1..69975c2d 100644 --- a/tests/test_network_policy.py +++ b/tests/test_network_policy.py @@ -23,13 +23,20 @@ def __init__(self, family, socktype): self.timeout = None self.connected_to = None self.closed = False + self.socket_options = [] + self.operations = [] def settimeout(self, timeout): self.timeout = timeout def connect(self, address): + self.operations.append(('connect', address)) self.connected_to = address + def setsockopt(self, level, option, value): + self.operations.append(('setsockopt', level, option, value)) + self.socket_options.append((level, option, value)) + def close(self): self.closed = True @@ -134,6 +141,34 @@ def test_allow_internal_selects_private_candidate(monkeypatch): ).ip == '10.0.0.8' +def test_resolution_validator_selects_only_the_pinned_network_route( + monkeypatch, +): + monkeypatch.setattr( + socket, + 'getaddrinfo', + lambda *args, **kwargs: [ + addr(socket.AF_INET, '10.0.0.8'), + addr(socket.AF_INET, '100.64.0.9'), + ], + ) + checked = [] + + def tailnet_only(target): + checked.append(target.ip) + return target.ip.startswith('100.64.') + + target = resolve_allowed_target( + 'node.example', + 22, + allow_internal=True, + target_validator=tailnet_only, + ) + + assert target.ip == '100.64.0.9' + assert checked == ['10.0.0.8', '100.64.0.9'] + + @pytest.mark.parametrize( ('raw', 'canonical', 'ip', 'family'), [ @@ -198,6 +233,84 @@ def fake_socket(family, socktype): assert created[0].closed is True +@pytest.mark.parametrize( + ('family', 'ip', 'expected_option'), + [ + ( + socket.AF_INET, + '100.64.0.10', + (socket.IPPROTO_IP, 50, socket.htonl(52)), + ), + ( + socket.AF_INET6, + 'fd7a:115c:a1e0::10', + (socket.IPPROTO_IPV6, 76, socket.htonl(52)), + ), + ], +) +def test_open_socket_binds_required_interface_before_connect( + monkeypatch, + family, + ip, + expected_option, +): + created = [] + + def fake_socket(socket_family, socktype): + result = RecordingSocket(socket_family, socktype) + created.append(result) + return result + + monkeypatch.setattr(socket, 'socket', fake_socket) + monkeypatch.setattr(socket, 'if_nametoindex', lambda name: 52) + target = ResolvedTarget('tail-node', 22, ip, family) + + connected = open_validated_socket( + target, + timeout=3, + required_interface='tailscale0', + ) + + assert connected is created[0] + assert connected.socket_options == [expected_option] + assert connected.connected_to == target.sockaddr + assert connected.operations == [ + ('setsockopt', *expected_option), + ('connect', target.sockaddr), + ] + + +def test_open_socket_closes_before_connect_when_interface_binding_fails( + monkeypatch, +): + created = [] + + def fake_socket(family, socktype): + result = RecordingSocket(family, socktype) + created.append(result) + return result + + monkeypatch.setattr(socket, 'socket', fake_socket) + monkeypatch.setattr( + socket, + 'if_nametoindex', + lambda _name: (_ for _ in ()).throw(OSError('missing interface')), + ) + target = ResolvedTarget( + 'tail-node', 22, '100.64.0.10', socket.AF_INET + ) + + with pytest.raises(OSError, match='missing interface'): + open_validated_socket( + target, + timeout=3, + required_interface='tailscale0', + ) + + assert created[0].connected_to is None + assert created[0].closed is True + + def test_ipv6_preserves_exact_resolver_sockaddr(monkeypatch): resolver_sockaddr = ( '2606:4700:4700::1111', diff --git a/tests/test_paramiko_channels.py b/tests/test_paramiko_channels.py index 68e8aeb6..69f5d6ee 100644 --- a/tests/test_paramiko_channels.py +++ b/tests/test_paramiko_channels.py @@ -120,8 +120,8 @@ def open_session(self, timeout=None): transport = Transport() marker = object() monkeypatch.setattr( - paramiko_channels.paramiko, - 'SFTPClient', + paramiko_channels, + 'BoundedSFTPClient', lambda channel: marker, ) @@ -166,10 +166,48 @@ def open_session(self, timeout=None): guard = type('Guard', (), {'cancel': lambda self: None})() monkeypatch.setattr(paramiko_channels, '_request_guard', lambda *_args: guard) - monkeypatch.setattr(paramiko_channels.paramiko, 'SFTPClient', lambda _channel: object()) + monkeypatch.setattr( + paramiko_channels, + 'BoundedSFTPClient', + lambda _channel: object(), + ) paramiko_channels.open_sftp_client( Transport(), timeout=5, operation_timeout=5, deadline=12.0 ) assert channel.timeouts == pytest.approx([1.6, 1.2]) + + +def test_sftp_packet_limit_closes_channel_before_declared_body_read( + monkeypatch, +): + import struct + import config + from app import paramiko_channels + from paramiko.sftp import SFTPError + + monkeypatch.setattr(config, 'SFTP_MAX_PACKET_BYTES', 1024) + + class Socket: + def __init__(self): + self.closed = False + self.reads = [] + + def recv(self, size): + self.reads.append(size) + if len(self.reads) == 1: + return struct.pack('>I', 1025) + raise AssertionError('oversized SFTP packet body was read') + + def close(self): + self.closed = True + + client = object.__new__(paramiko_channels.BoundedSFTPClient) + client.sock = Socket() + + with pytest.raises(SFTPError, match='packet exceeds'): + client._read_packet() + + assert client.sock.reads == [4] + assert client.sock.closed is True diff --git a/tests/test_post_connect_manager.py b/tests/test_post_connect_manager.py index 5ffc525b..37aa0807 100644 --- a/tests/test_post_connect_manager.py +++ b/tests/test_post_connect_manager.py @@ -104,6 +104,44 @@ def test_validate_projects_only_fields_for_selected_mode(app, monkeypatch): assert empty == {'startup_mode': 'none'} +@pytest.mark.parametrize('override', [ + pytest.param('x' * 4097, id='ascii'), + pytest.param('\U0001f512' * 4097, id='multibyte'), +]) +def test_validate_rejects_oversized_override_before_resolution( + app, + monkeypatch, + override, +): + from app import command_manager, post_connect_manager + + monkeypatch.setattr( + command_manager, 'get_all_commands', + lambda user_id, os_filter=None: library_commands(), + ) + monkeypatch.setattr( + post_connect_manager, + '_resolve_command', + lambda *_args, **_kwargs: pytest.fail( + 'oversized override reached command concatenation' + ), + ) + user_id = create_user(app) + + with app.app_context(): + stored, error = post_connect_manager.validate_configuration( + user_id, + { + 'startup_mode': 'command', + 'command_id': 'cmd-echo', + 'parameters_override': override, + }, + ) + + assert stored is None + assert error == 'Command parameters must not exceed 4096 characters' + + def test_resolve_single_command_uses_default_or_override_parameters(app, monkeypatch): from app import command_manager from app.post_connect_manager import resolve_configuration diff --git a/tests/test_production_config.py b/tests/test_production_config.py index d665bd2d..521b4461 100644 --- a/tests/test_production_config.py +++ b/tests/test_production_config.py @@ -40,6 +40,11 @@ 'SMB_ALLOWED_TARGETS', 'SMB_ENABLED', 'STEP_UP_MAX_AGE_SECONDS', + 'TAILSCALE_SSH_ALLOWED_REMOTE_USERS', + 'TAILSCALE_SSH_ALLOWED_TARGETS', + 'TAILSCALE_SSH_ALLOWED_WEBSSH_USERS', + 'TAILSCALE_SSH_ENABLED', + 'TAILSCALE_SSH_INTERFACE', 'TOTP_ENABLED', 'TRUSTED_PROXIES', 'WEBAUTHN_ENABLED', @@ -92,6 +97,131 @@ def test_safe_production_profile_loads(): assert result.returncode == 0, result.stdout + result.stderr +@pytest.mark.parametrize( + 'target', + ( + 'tiny-server', + 'tiny-server:2200', + '100.64.0.10', + '100.64.0.10:2200', + 'fd7a:115c:a1e0::10', + '[fd7a:115c:a1e0::10]:2200', + ), +) +def test_production_tailscale_accepts_supported_exact_targets(target): + result = _load_config(_production_env( + TAILSCALE_SSH_ENABLED='true', + TAILSCALE_SSH_ALLOWED_TARGETS=target, + TAILSCALE_SSH_INTERFACE='tailscale0', + )) + + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize( + 'targets', + ( + '', + 'bad target', + 'tiny-server,bad target', + '[fd7a:115c:a1e0::10', + 'tiny-server:65536', + ), +) +def test_production_tailscale_rejects_empty_or_malformed_targets(targets): + result = _load_config(_production_env( + TAILSCALE_SSH_ENABLED='true', + TAILSCALE_SSH_ALLOWED_TARGETS=targets, + TAILSCALE_SSH_INTERFACE='tailscale0', + )) + + assert result.returncode != 0 + assert 'TAILSCALE_SSH_ALLOWED_TARGETS' in result.stdout + result.stderr + + +def test_production_tailscale_rejects_empty_interface(): + result = _load_config(_production_env( + TAILSCALE_SSH_ENABLED='true', + TAILSCALE_SSH_ALLOWED_TARGETS='tiny-server', + TAILSCALE_SSH_INTERFACE=' ', + )) + + assert result.returncode != 0 + assert 'TAILSCALE_SSH_INTERFACE' in result.stdout + result.stderr + + +def test_disabled_tailscale_tolerates_dormant_policy_values(): + result = _load_config( + _production_env( + TAILSCALE_SSH_ENABLED='false', + TAILSCALE_SSH_ALLOWED_TARGETS='bad target', + TAILSCALE_SSH_INTERFACE='', + ), + 'import json, config; ' + 'print(json.dumps(config.SECURITY_CONFIG_WARNINGS))', + ) + + assert result.returncode == 0, result.stdout + result.stderr + assert 'TAILSCALE_SSH_' not in result.stdout.splitlines()[-1] + + +@pytest.mark.parametrize( + ('overrides', 'warning_fragments'), + ( + ( + { + 'TAILSCALE_SSH_ALLOWED_TARGETS': '', + 'TAILSCALE_SSH_INTERFACE': 'tailscale0', + }, + ('TAILSCALE_SSH_ALLOWED_TARGETS is empty', 'fail closed'), + ), + ( + { + 'TAILSCALE_SSH_ALLOWED_TARGETS': 'tiny-server,bad target', + 'TAILSCALE_SSH_INTERFACE': 'tailscale0', + }, + ('TAILSCALE_SSH_ALLOWED_TARGETS contains malformed entries',), + ), + ( + { + 'TAILSCALE_SSH_ALLOWED_TARGETS': 'bad target', + 'TAILSCALE_SSH_INTERFACE': 'tailscale0', + }, + ( + 'TAILSCALE_SSH_ALLOWED_TARGETS contains malformed entries', + 'TAILSCALE_SSH_ALLOWED_TARGETS contains no valid targets', + 'fail closed', + ), + ), + ( + { + 'TAILSCALE_SSH_ALLOWED_TARGETS': 'tiny-server', + 'TAILSCALE_SSH_INTERFACE': ' ', + }, + ('TAILSCALE_SSH_INTERFACE is empty', 'fail closed'), + ), + ), +) +def test_homelab_tailscale_misconfiguration_warns_without_blocking_startup( + overrides, + warning_fragments, +): + result = _load_config( + _production_env( + DEPLOYMENT_PROFILE='homelab', + TAILSCALE_SSH_ENABLED='true', + **overrides, + ), + 'import json, config; ' + 'print(json.dumps(config.SECURITY_CONFIG_WARNINGS))', + ) + + assert result.returncode == 0, result.stdout + result.stderr + warnings = result.stdout.splitlines()[-1] + for fragment in warning_fragments: + assert fragment in warnings + + def test_smb_is_disabled_by_default(): result = _load_config( _production_env(), diff --git a/tests/test_profile_manager.py b/tests/test_profile_manager.py index ea9b19ad..f7da67ee 100644 --- a/tests/test_profile_manager.py +++ b/tests/test_profile_manager.py @@ -109,6 +109,31 @@ def test_profile_tmux_preference_is_validated_and_persisted(app): assert profile_manager.load_profiles(user_id)[0]['use_tmux'] is False +def test_profile_persistence_strips_response_only_tailscale_authorization(app): + from app import profile_manager + + user_id = create_user(app, 'tailscale-response-only') + profile = { + 'id': 'tailscale-profile', + 'name': 'Tailnet server', + 'host': 'tiny-server', + 'port': 22, + 'username': 'root', + 'auth_type': 'tailscale', + 'tailscale_authorized': True, + } + with app.app_context(): + assert profile_manager.save_profiles(user_id, [profile]) is True + stored = profile_manager.load_profiles(user_id) + + assert profile['tailscale_authorized'] is True + assert stored == [{ + key: value + for key, value in profile.items() + if key != 'tailscale_authorized' + }] + + def test_profile_edit_without_tmux_field_preserves_existing_preference(app): from app import profile_manager @@ -157,6 +182,25 @@ def test_profile_rejects_non_boolean_tmux_preference(app): assert profile_manager.load_profiles(user_id) == [] +def test_profile_rejects_tailscale_with_jump_host(app): + from app import profile_manager + + user_id = create_user(app, 'tailscale-jump-profile') + with app.app_context(): + profile, error = profile_manager.upsert_profile(user_id, { + 'name': 'Invalid routed target', + 'host': 'server.tailnet.ts.net', + 'port': 22, + 'username': 'deploy', + 'auth_type': 'tailscale', + 'jump_host_id': 'jump-host-1', + }) + + assert profile is None + assert error == 'Tailscale SSH cannot be used with a jump host' + assert profile_manager.load_profiles(user_id) == [] + + @pytest.mark.parametrize('sort_order', [True, -1, '1', 1.5]) def test_profile_document_rejects_invalid_sort_order(app, sort_order): from app import profile_manager @@ -970,3 +1014,537 @@ def test_profile_update_preserves_unknown_stored_fields(app): assert error is None assert updated['future'] == {'version': 2} + + +def test_profile_count_quota_is_atomic_and_delete_remains_available( + app, + monkeypatch, +): + import config + from app import profile_manager + + user_id = create_user(app, 'profile-count-quota') + monkeypatch.setattr(config, 'PROFILE_MAX_RECORDS', 1) + payload = { + 'name': 'First', + 'host': 'example.com', + 'port': 22, + 'username': 'deploy', + 'auth_type': 'password', + } + with app.app_context(): + first, error = profile_manager.upsert_profile(user_id, payload) + assert error is None + + second, error = profile_manager.upsert_profile( + user_id, {**payload, 'name': 'Second'} + ) + + assert second is None + assert error.startswith('Connection storage quota exceeded:') + assert profile_manager.load_profiles(user_id) == [first] + assert profile_manager.delete_profile(user_id, first['id']) == ( + True, None + ) + + +def test_profile_cannot_reference_another_users_ssh_key( + app, + rsa_private_key_pem, +): + from app import key_manager, profile_manager + + owner_id = create_user(app, 'profile-key-owner') + attacker_id = create_user(app, 'profile-key-attacker') + with app.app_context(): + key, error = key_manager.save_key( + owner_id, 'Owner key', rsa_private_key_pem + ) + assert error is None + + profile, error = profile_manager.upsert_profile(attacker_id, { + 'name': 'Foreign key', + 'host': 'example.com', + 'port': 22, + 'username': 'deploy', + 'auth_type': 'key', + 'key_id': key['id'], + }) + + assert profile is None + assert error == 'SSH key not found' + + +def test_legacy_oversized_profile_store_has_bounded_offline_recovery( + app, + monkeypatch, +): + import json + import config + from app import command_set_manager, profile_manager + from app.connection_storage_policy import ConnectionStorageLimitError + from app.storage_migrations import CURRENT_STORAGE_VERSIONS + + user_id = create_user(app, 'oversized-profile-recovery') + profile = { + 'id': 'legacy-large', + 'name': 'Legacy', + 'future': 'x' * 1024, + } + with app.app_context(): + command_set, error = command_set_manager.upsert_command_set(user_id, { + 'name': 'Recovery guard', + 'steps': [{'type': 'inline', 'command': 'true'}], + }) + assert error is None + path = profile_manager.get_user_profiles_file(user_id) + path.write_text(json.dumps({ + 'schema_version': CURRENT_STORAGE_VERSIONS['profiles'], + 'profiles': [profile], + }), encoding='utf-8') + monkeypatch.setattr(config, 'CONNECTION_STORE_MAX_BYTES', 256) + + with pytest.raises(ConnectionStorageLimitError): + profile_manager.load_profiles(user_id) + summaries, summary_error = ( + profile_manager.load_profile_recovery_summaries(user_id) + ) + assert summary_error is None + assert summaries == [{ + 'id': 'legacy-large', + 'name': 'Legacy', + 'host': '', + 'selector': summaries[0]['selector'], + }] + expected = ( + 'Connection storage quota exceeded: stored data exceeds its byte limit' + ) + assert profile_manager.upsert_profile(user_id, { + 'name': 'New', + 'host': 'example.com', + 'port': 22, + 'username': 'deploy', + 'auth_type': 'password', + }) == (None, expected) + assert profile_manager.update_profile_organization( + user_id, profile['id'], {'favorite': True} + ) == (None, expected) + assert profile_manager.move_profile( + user_id, profile['id'], '', 'Production', 0 + ) == (None, expected) + assert profile_manager.assign_command_set( + user_id, profile['id'], command_set['id'] + ) == (None, expected) + assert profile_manager.delete_profile( + user_id, profile['id'] + ) == (False, expected) + assert profile_manager.delete_profile_recovery_record( + user_id, summaries[0]['selector'] + ) == (True, None) + assert profile_manager.load_profiles(user_id) == [] + + +def test_normal_profile_delete_removes_only_first_duplicate_id(app): + from app import profile_manager + + user_id = create_user(app, 'duplicate-profile-delete') + with app.app_context(): + assert profile_manager.save_profiles(user_id, [ + {'id': 'duplicate-id', 'name': 'First'}, + {'id': 'duplicate-id', 'name': 'Second'}, + ]) + + assert profile_manager.delete_profile( + user_id, + 'duplicate-id', + ) == (True, None) + + assert profile_manager.load_profiles(user_id) == [ + {'id': 'duplicate-id', 'name': 'Second'}, + ] + + +def test_normal_profile_delete_from_compact_limit_store_stays_readable( + app, + monkeypatch, +): + import json + import config + from app import profile_manager + from app.storage_migrations import CURRENT_STORAGE_VERSIONS + + user_id = create_user(app, 'compact-profile-delete') + profiles = [ + {'id': str(index), 'name': 'x'} + for index in range(50) + ] + document = { + 'schema_version': CURRENT_STORAGE_VERSIONS['profiles'], + 'profiles': profiles, + } + original = json.dumps(document, separators=(',', ':')).encode('utf-8') + + with app.app_context(): + path = profile_manager.get_user_profiles_file(user_id) + path.write_bytes(original) + monkeypatch.setattr( + config, + 'CONNECTION_STORE_MAX_BYTES', + len(original), + ) + monkeypatch.setattr( + config, + 'CONNECTION_CONFIG_MAX_BYTES', + len(original), + ) + + assert profile_manager.delete_profile( + user_id, + profiles[-1]['id'], + ) == (True, None) + + expected = json.dumps({ + **document, + 'profiles': profiles[:-1], + }, separators=(',', ':')).encode('utf-8') + assert path.read_bytes() == expected + assert len(expected) < len(original) + assert profile_manager.load_profiles(user_id) == profiles[:-1] + + +def test_profile_shrink_uses_compact_fallback_at_combined_cap( + app, + monkeypatch, +): + import json + import config + from app import profile_manager + from app.storage_migrations import CURRENT_STORAGE_VERSIONS + + user_id = create_user(app, 'compact-profile-update') + profiles = [ + { + 'id': f'profile-{index}', + 'name': 'x' * 128, + 'host': 'example.com', + 'port': 22, + 'username': 'deploy', + 'auth_type': 'password', + 'key_id': None, + 'startup_mode': 'none', + 'created_at': '2026-01-01T00:00:00.000000+00:00', + 'updated_at': '2026-01-01T00:00:00.000000+00:00', + 'sort_order': index, + } + for index in range(20) + ] + profile = profiles[0] + document = { + 'schema_version': CURRENT_STORAGE_VERSIONS['profiles'], + 'profiles': profiles, + } + original = json.dumps(document, separators=(',', ':')).encode('utf-8') + jump_document = { + 'schema_version': CURRENT_STORAGE_VERSIONS['jump_hosts'], + 'jump_hosts': [], + } + jump_bytes = json.dumps( + jump_document, + separators=(',', ':'), + ).encode('utf-8') + + with app.app_context(): + path = profile_manager.get_user_profiles_file(user_id) + path.write_bytes(original) + (path.parent / 'jump_hosts.json').write_bytes(jump_bytes) + monkeypatch.setattr(config, 'CONNECTION_STORE_MAX_BYTES', 100_000) + monkeypatch.setattr( + config, + 'CONNECTION_CONFIG_MAX_BYTES', + len(original) + len(jump_bytes), + ) + + updated, error = profile_manager.upsert_profile(user_id, { + 'id': profile['id'], + 'name': 'Short', + 'host': profile['host'], + 'port': profile['port'], + 'username': profile['username'], + 'auth_type': profile['auth_type'], + }) + + assert error is None + assert updated['name'] == 'Short' + persisted = path.read_bytes() + persisted_document = json.loads(persisted.decode('utf-8')) + assert persisted == json.dumps( + persisted_document, + separators=(',', ':'), + ).encode('utf-8') + assert len(persisted) < len(original) + assert len(persisted) + len(jump_bytes) <= ( + config.CONNECTION_CONFIG_MAX_BYTES + ) + reloaded = profile_manager.load_profiles(user_id) + assert reloaded[0] == updated + assert [item['id'] for item in reloaded[1:]] == [ + item['id'] for item in profiles[1:] + ] + + +def test_profile_recovery_ceiling_rejects_before_json_load(app, monkeypatch): + import config + from app import profile_manager + from app.connection_storage_policy import ConnectionStorageLimitError + + user_id = create_user(app, 'profile-recovery-hard-limit') + with app.app_context(): + path = profile_manager.get_user_profiles_file(user_id) + path.write_bytes(b'x' * 257) + monkeypatch.setattr(config, 'CONNECTION_STORE_RECOVERY_MAX_BYTES', 256) + monkeypatch.setattr( + profile_manager, + 'load_json_migrated', + lambda *_args, **_kwargs: pytest.fail( + 'oversized recovery store was parsed' + ), + ) + + with pytest.raises(ConnectionStorageLimitError): + profile_manager.load_profile_recovery_summaries(user_id) + + deleted, error = profile_manager.delete_profile_recovery_record( + user_id, + 'r1:0:' + ('0' * 64), + ) + + assert deleted is False + assert error == ( + 'Connection storage quota exceeded: stored data exceeds its recovery ' + 'byte limit' + ) + + +def test_profile_recovery_record_ceiling_rejects_after_bounded_load( + app, + monkeypatch, +): + import json + import config + from app import profile_manager, storage_migrations + + user_id = create_user(app, 'profile-recovery-record-limit') + document = { + 'schema_version': 1, + 'profiles': [ + {'id': 'target', 'name': 'Target'}, + {'id': 'other', 'name': 'Other'}, + ], + } + with app.app_context(): + path = profile_manager.get_user_profiles_file(user_id) + original = json.dumps(document, separators=(',', ':')).encode('utf-8') + path.write_bytes(original) + monkeypatch.setattr( + config, + 'CONNECTION_STORE_RECOVERY_MAX_RECORDS', + 1, + ) + monkeypatch.setattr( + storage_migrations, + 'migrate_document', + lambda *_args: pytest.fail('over-record store was migrated'), + ) + + deleted, error = profile_manager.delete_profile_recovery_record( + user_id, + 'r1:0:' + ('0' * 64), + ) + + assert path.read_bytes() == original + assert list(path.parent.glob('profiles.json.*.bak')) == [] + + assert deleted is False + assert error == ( + 'Connection storage quota exceeded: more than 1 recovery records ' + 'are not allowed' + ) + + +def test_profile_recovery_delete_persists_valid_legacy_shrink( + app, + monkeypatch, +): + import json + import config + from app import profile_manager + from app.storage_migrations import CURRENT_STORAGE_VERSIONS + + user_id = create_user(app, 'profile-recovery-legacy-shrink') + with app.app_context(): + path = profile_manager.get_user_profiles_file(user_id) + original = json.dumps({ + 'schema_version': 1, + 'profiles': [ + {'id': 'target', 'name': 'Target'}, + {'id': 'other', 'name': 'Other'}, + ], + }, separators=(',', ':')).encode('utf-8') + path.write_bytes(original) + monkeypatch.setattr( + config, + 'CONNECTION_STORE_MAX_BYTES', + len(original) - 1, + ) + monkeypatch.setattr( + config, + 'CONNECTION_CONFIG_MAX_BYTES', + len(original) - 1, + ) + monkeypatch.setattr( + config, + 'CONNECTION_STORE_RECOVERY_MAX_BYTES', + 4096, + ) + + summaries, error = profile_manager.load_profile_recovery_summaries( + user_id + ) + assert error is None + selector = next( + item['selector'] for item in summaries if item['id'] == 'target' + ) + assert profile_manager.delete_profile_recovery_record( + user_id, + selector, + ) == (True, None) + + document = json.loads(path.read_text(encoding='utf-8')) + assert document['schema_version'] == CURRENT_STORAGE_VERSIONS['profiles'] + assert [item['id'] for item in document['profiles']] == ['other'] + assert path.read_bytes() == json.dumps( + document, + separators=(',', ':'), + ).encode('utf-8') + assert path.stat().st_size < len(original) + + +def test_profile_recovery_delete_compacts_exact_hard_cap_store( + app, + monkeypatch, +): + import json + import config + from app import profile_manager + from app.storage_migrations import CURRENT_STORAGE_VERSIONS + + user_id = create_user(app, 'profile-recovery-compact-cap') + profiles = [ + {'id': str(index), 'name': 'x'} + for index in range(50) + ] + document = { + 'schema_version': CURRENT_STORAGE_VERSIONS['profiles'], + 'profiles': profiles, + } + original = json.dumps(document, separators=(',', ':')).encode('utf-8') + assert len(original) == 1173 + + with app.app_context(): + path = profile_manager.get_user_profiles_file(user_id) + path.write_bytes(original) + monkeypatch.setattr(config, 'CONNECTION_STORE_MAX_BYTES', 64) + monkeypatch.setattr(config, 'CONNECTION_CONFIG_MAX_BYTES', 64) + monkeypatch.setattr( + config, + 'CONNECTION_STORE_RECOVERY_MAX_BYTES', + len(original), + ) + + summaries, error = profile_manager.load_profile_recovery_summaries( + user_id + ) + assert error is None + assert profile_manager.delete_profile_recovery_record( + user_id, + summaries[-1]['selector'], + ) == (True, None) + + expected = json.dumps({ + 'schema_version': CURRENT_STORAGE_VERSIONS['profiles'], + 'profiles': profiles[:-1], + }, separators=(',', ':')).encode('utf-8') + assert path.read_bytes() == expected + assert len(expected) < len(original) + + +def test_profile_recovery_delete_never_grows_migrated_store( + app, + monkeypatch, +): + import json + import config + from app import profile_manager + + user_id = create_user(app, 'profile-recovery-migration-no-growth') + document = { + 'schema_version': 1, + 'profiles': [ + {'id': str(index), 'name': 'x'} + for index in range(3) + ], + } + original = json.dumps(document, separators=(',', ':')).encode('utf-8') + assert len(original) == 99 + + with app.app_context(): + path = profile_manager.get_user_profiles_file(user_id) + path.write_bytes(original) + monkeypatch.setattr( + config, + 'CONNECTION_STORE_MAX_BYTES', + len(original) - 1, + ) + monkeypatch.setattr( + config, + 'CONNECTION_CONFIG_MAX_BYTES', + len(original) - 1, + ) + monkeypatch.setattr( + config, + 'CONNECTION_STORE_RECOVERY_MAX_BYTES', + 4096, + ) + summaries, error = profile_manager.load_profile_recovery_summaries( + user_id + ) + assert error is None + + deleted, error = profile_manager.delete_profile_recovery_record( + user_id, + summaries[-1]['selector'], + ) + + assert deleted is False + assert error == ( + 'Connection storage quota exceeded: recovery deletion would grow ' + 'its connection store' + ) + assert path.read_bytes() == original + + +def test_normal_profile_save_keeps_pretty_json(app): + import json + from app import profile_manager + from app.storage_migrations import CURRENT_STORAGE_VERSIONS + + user_id = create_user(app, 'profile-normal-pretty-json') + profiles = [{'id': 'profile-1', 'name': 'Production'}] + with app.app_context(): + path = profile_manager.get_user_profiles_file(user_id) + assert profile_manager.save_profiles(user_id, profiles) is True + + assert path.read_bytes() == json.dumps({ + 'schema_version': CURRENT_STORAGE_VERSIONS['profiles'], + 'profiles': profiles, + }, indent=2).encode('utf-8') diff --git a/tests/test_remote_transfer.py b/tests/test_remote_transfer.py index a800c5ca..8b7ee087 100644 --- a/tests/test_remote_transfer.py +++ b/tests/test_remote_transfer.py @@ -29,34 +29,53 @@ def write(self, data): class _Backend: - def __init__(self, files=None, tree=None): + def __init__(self, files=None, tree=None, root_identities=None): self.files = dict(files or {}) self.tree = list(tree or []) self.readers = [] + self.reader_identities = [] self.commits = [] self.created = [] self.writer_opens = 0 + self.stat_paths = [] + self.root_identities = root_identities + self.tree_identities = [] def normalize_path(self, path): return path if isinstance(path, str) and path.startswith('/') else None def stat(self, _source, path, *, follow_links=False): assert follow_links is False + self.stat_paths.append(path) if path in self.files: - return { + result = { 'path': path, 'size': len(self.files[path]), 'is_dir': False, 'is_symlink': False, - }, None + } + if self.root_identities is not None: + result['_smb_identity_chain'] = self.root_identities + return result, None if path == '/folder': - return { + result = { 'path': path, 'size': 0, 'is_dir': True, 'is_symlink': False, - }, None + } + if self.root_identities is not None: + result['_smb_identity_chain'] = self.root_identities + return result, None return None, 'File or directory not found' @contextmanager - def open_reader(self, _source, path, *, io_lane='control'): + def open_reader( + self, + _source, + path, + *, + io_lane='control', + _expected_identities=None, + ): assert io_lane == 'transfer' + self.reader_identities.append(_expected_identities) reader = _BoundedReader(self.files[path]) self.readers.append(reader) with reader: @@ -83,10 +102,11 @@ def open_atomic_writer( def iter_tree( self, _source, _path, *, budget, cancel_event, - follow_links=False, io_lane='control', + follow_links=False, io_lane='control', _expected_identities=None, ): assert follow_links is False assert io_lane == 'transfer' + self.tree_identities.append(_expected_identities) for entry in self.tree: budget.consume() if cancel_event.is_set(): @@ -142,6 +162,67 @@ def test_remote_copy_streams_through_one_atomic_commit( assert max(source_backend.readers[0].read_sizes) == 31 +def test_single_file_copy_binds_reader_to_classified_smb_root(): + from app.remote_transfer import TransferBudget, copy_remote_entry + + identity_chain = (71, 73) + source_backend = _Backend( + {'/source.bin': b'bound'}, + root_identities=identity_chain, + ) + destination_backend = _Backend() + + copy_remote_entry( + _source('smb', source_backend), + '/source.bin', + _source('sftp', destination_backend), + '/target.bin', + conflict_policy='replace', + budget=TransferBudget(max_bytes=10, max_members=1), + cancel_event=Event(), + progress=None, + chunk_size=2, + ) + + assert source_backend.reader_identities == [identity_chain] + assert destination_backend.files['/target.bin'] == b'bound' + + +def test_directory_copy_binds_traversal_to_classified_smb_root(): + from app.remote_transfer import TransferBudget, copy_remote_entry + + root_chain = (41,) + leaf_chain = (41, 73) + source_backend = _Backend( + files={'/folder/a.bin': b'bound'}, + tree=[{ + 'name': 'a.bin', + 'path': '/folder/a.bin', + 'size': 5, + 'is_dir': False, + 'is_symlink': False, + '_smb_identity_chain': leaf_chain, + }], + root_identities=root_chain, + ) + destination_backend = _Backend() + + copy_remote_entry( + _source('smb', source_backend), + '/folder', + _source('sftp', destination_backend), + '/copy', + conflict_policy='replace', + budget=TransferBudget(max_bytes=10, max_members=2), + cancel_event=Event(), + progress=None, + chunk_size=2, + ) + + assert source_backend.tree_identities == [root_chain] + assert source_backend.reader_identities == [leaf_chain] + + def test_remote_copy_rejects_opened_object_before_destination_writer(): """A small path stat must not authorize a larger substituted object.""" from app.remote_transfer import ( @@ -291,6 +372,94 @@ def test_directory_total_size_is_rejected_before_destination_mutation(): assert destination_backend.commits == [] +def test_directory_copy_binds_reader_to_enumerated_smb_identity_chain(): + from app.remote_transfer import TransferBudget, copy_remote_entry + + identity_chain = (41, 73) + source_backend = _Backend( + files={'/folder/a.bin': b'bound'}, + tree=[{ + 'name': 'a.bin', + 'path': '/folder/a.bin', + 'size': 5, + 'is_dir': False, + 'is_symlink': False, + '_smb_identity_chain': identity_chain, + }], + ) + destination_backend = _Backend() + + copy_remote_entry( + _source('smb', source_backend), + '/folder', + _source('sftp', destination_backend), + '/copy', + conflict_policy='replace', + budget=TransferBudget(max_bytes=10, max_members=2), + cancel_event=Event(), + progress=None, + chunk_size=2, + ) + + assert source_backend.reader_identities == [identity_chain] + assert source_backend.stat_paths == ['/folder'] + assert destination_backend.files['/copy/a.bin'] == b'bound' + + +@pytest.mark.parametrize('race', ['missing', 'reparse', 'directory']) +def test_directory_copy_preserves_bound_smb_source_change(race): + from app.file_backend import FileSourceChanged + from app.remote_transfer import TransferBudget, copy_remote_entry + + identity_chain = (41, 73) + + class ChangedBackend(_Backend): + @contextmanager + def open_reader( + self, + _source, + path, + *, + io_lane='control', + _expected_identities=None, + ): + assert path == '/folder/a.bin' + assert io_lane == 'transfer' + assert _expected_identities == identity_chain + raise FileSourceChanged(f'enumerated source became {race}') + yield # pragma: no cover - contextmanager contract + + source_backend = ChangedBackend( + files={'/folder/a.bin': b'old'}, + tree=[{ + 'name': 'a.bin', + 'path': '/folder/a.bin', + 'size': 3, + 'is_dir': False, + 'is_symlink': False, + '_smb_identity_chain': identity_chain, + }], + ) + + with pytest.raises(FileSourceChanged) as error: + copy_remote_entry( + _source('smb', source_backend), + '/folder', + _source('sftp', _Backend()), + '/copy', + conflict_policy='replace', + budget=TransferBudget(max_bytes=10, max_members=2), + cancel_event=Event(), + progress=None, + chunk_size=2, + ) + + assert error.value.public_code == 'SOURCE_CHANGED' + # Only the root classification is unbound. The enumerated leaf goes + # directly through its expected-ID reader instead of a pathname re-stat. + assert source_backend.stat_paths == ['/folder'] + + def test_same_source_and_path_is_rejected_as_a_noop_conflict(): from app.remote_transfer import ( RemoteTransferConflict, @@ -328,6 +497,75 @@ def stat_or_raise(self, _source, _path, *, follow_links=False): ) +def test_remote_copy_preserves_source_identity_change_classification(): + from app.file_backend import FileSourceChanged + from app.remote_transfer import TransferBudget, copy_remote_entry + from app.transfer_errors import classify_transfer_failure + + class ChangedSourceBackend(_Backend): + @contextmanager + def open_reader( + self, + _source, + _path, + *, + io_lane='control', + _expected_identities=None, + ): + assert io_lane == 'transfer' + raise FileSourceChanged('private source path') + yield # pragma: no cover - required by contextmanager semantics + + with pytest.raises(FileSourceChanged) as failure: + copy_remote_entry( + _source('smb', ChangedSourceBackend({'/source.bin': b'value'})), + '/source.bin', + _source('smb', _Backend()), + '/target.bin', + conflict_policy='error', + budget=TransferBudget(max_bytes=10, max_members=1), + cancel_event=Event(), progress=None, chunk_size=2, + ) + + assert classify_transfer_failure( + failure.value, operation='remote_transfer' + ).code == 'SOURCE_CHANGED' + + +def test_remote_copy_preserves_backend_enumeration_cancellation(): + from app.file_backend import FileOperationCancelled + from app.remote_transfer import TransferBudget, copy_remote_entry + from app.transfer_errors import classify_transfer_failure + + class CancelledTreeBackend(_Backend): + def iter_tree( + self, _source, _path, *, budget, cancel_event, + follow_links=False, io_lane='control', + ): + assert follow_links is False + assert io_lane == 'transfer' + raise FileOperationCancelled('private backend detail') + yield # pragma: no cover - generator contract + + with pytest.raises(FileOperationCancelled) as failure: + copy_remote_entry( + _source('smb', CancelledTreeBackend()), + '/folder', + _source('smb', _Backend()), + '/copy', + conflict_policy='error', + budget=TransferBudget(max_bytes=10, max_members=2), + cancel_event=Event(), + progress=None, + chunk_size=2, + ) + + assert classify_transfer_failure( + failure.value, + operation='remote_transfer', + ).code == 'CANCELLED' + + def test_typed_destination_directory_permission_failure_is_not_collapsed(): from app.remote_transfer import TransferBudget, copy_remote_entry diff --git a/tests/test_restore_sanitizer.py b/tests/test_restore_sanitizer.py index bf418c4f..22d45584 100644 --- a/tests/test_restore_sanitizer.py +++ b/tests/test_restore_sanitizer.py @@ -2,6 +2,7 @@ TRANSIENT_TABLES = ( + 'factor_bootstrap_tokens', 'github_oauth_states', 'oidc_login_states', 'step_up_grants', diff --git a/tests/test_restore_web.py b/tests/test_restore_web.py index 2c7ba75d..794e854e 100644 --- a/tests/test_restore_web.py +++ b/tests/test_restore_web.py @@ -3,6 +3,7 @@ from types import SimpleNamespace import config +import pytest def _configure_temp_root(monkeypatch, tmp_path): @@ -85,6 +86,80 @@ def test_rollback_failure_archive_survives_orphan_cleanup( assert maintenance.public_status()['state'] == 'idle' +@pytest.mark.parametrize('status_payload', [ + b'{not-json', + b'null', + b'[]', + b'{}', + b'{"state":"unexpected"}', +]) +def test_corrupt_maintenance_status_preserves_all_recovery_archives( + client, + monkeypatch, + tmp_path, + status_payload, +): + maintenance = _configure_temp_root(monkeypatch, tmp_path) + from app.backup_operations import BackupOperationRegistry + + root = maintenance._status_path().parent + operation = root / 'operation-unknown' + operation.mkdir() + rollback = operation / 'rollback.zip' + rollback.write_bytes(b'emergency') + maintenance._status_path().write_bytes(status_payload) + maintenance._state = None + maintenance._state_path = None + + BackupOperationRegistry().cleanup_orphans() + + assert rollback.read_bytes() == b'emergency' + assert maintenance.public_status()['state'] == 'rollback_failed' + assert client.get('/ready').status_code == 503 + + +@pytest.mark.parametrize('status_payload', [ + b'{not-json', + b'null', + b'[]', + b'{}', + b'{"state":"unexpected"}', +]) +def test_corrupt_maintenance_status_blocks_all_startup_initialization( + monkeypatch, + tmp_path, + status_payload, +): + import app as app_module + + maintenance = _configure_temp_root(monkeypatch, tmp_path) + maintenance._status_path().write_bytes(status_payload) + maintenance._state = None + maintenance._state_path = None + initialized = [] + runtime_jobs = [] + monkeypatch.setattr( + app_module, + '_initialize_persistent_storage', + lambda created_app: initialized.append(created_app), + ) + monkeypatch.setattr( + app_module.RuntimeLifecycle, + 'start_job', + lambda self, name, target: runtime_jobs.append(name), + ) + + created_app = app_module.create_app( + initialize_storage=True, + start_runtime=True, + initialize_oidc=False, + ) + + assert initialized == [] + assert runtime_jobs == [] + assert created_app.test_client().get('/ready').status_code == 503 + + def test_interrupted_restore_sanitizes_rollback_before_epoch_rotation( monkeypatch, tmp_path ): @@ -139,6 +214,37 @@ def fake_operation_lock(): assert maintenance.public_status()['state'] == 'failed' +def test_interrupted_restore_with_mismatched_data_dir_stays_fail_closed( + monkeypatch, tmp_path +): + import json + + maintenance = _configure_temp_root(monkeypatch, tmp_path) + root = maintenance._status_path().parent + operation = root / 'operation-mismatch' + operation.mkdir() + rollback = operation / 'rollback.zip' + rollback.write_bytes(b'emergency') + maintenance.begin_preparing('mismatch') + maintenance.mark_in_progress( + 'mismatch', 'operation-mismatch/rollback.zip' + ) + document = json.loads(maintenance._status_path().read_text('utf-8')) + document['data_fingerprint'] = '0' * 64 + maintenance._status_path().write_text(json.dumps(document), 'utf-8') + maintenance._state = None + maintenance._state_path = None + + maintenance.recover_interrupted_restore() + + assert maintenance.is_active() is True + assert maintenance.public_status()['state'] == 'rollback_failed' + assert maintenance.protected_operation_directory_name() == ( + 'operation-mismatch' + ) + assert rollback.read_bytes() == b'emergency' + + def test_failed_restore_runs_emergency_rollback_and_restarts( app, monkeypatch, tmp_path ): diff --git a/tests/test_security_ui.py b/tests/test_security_ui.py index ae85546c..7bfd2b56 100644 --- a/tests/test_security_ui.py +++ b/tests/test_security_ui.py @@ -111,6 +111,23 @@ def test_settings_cards_keep_consistent_vertical_spacing(): ) +def test_factor_confirmation_uses_secret_specific_autocomplete_metadata(): + security_template = (ROOT / "templates/security.html").read_text( + encoding="utf-8" + ) + webauthn_script = (ROOT / "static/js/webauthn.js").read_text( + encoding="utf-8" + ) + + assert ( + 'id="securityConfirmationPassword" class="form-control" ' + 'autocomplete="current-password"' + ) in security_template + assert "settings.authentication === 'bootstrap'" in webauthn_script + assert "? 'one-time-code'" in webauthn_script + assert ": 'current-password';" in webauthn_script + + def test_linked_github_identity_is_presented_as_a_security_method(app, client): from app.models import GitHubIdentity, db diff --git a/tests/test_sftp_handler.py b/tests/test_sftp_handler.py index 3fbd9f41..3da6413d 100644 --- a/tests/test_sftp_handler.py +++ b/tests/test_sftp_handler.py @@ -3,6 +3,446 @@ import pytest +def test_remote_metadata_budget_counts_utf8_bytes_and_aggregate_overhead( + monkeypatch, +): + import config + import app.sftp_handler as sftp_handler + + monkeypatch.setattr(config, 'REMOTE_FILENAME_MAX_BYTES', 4) + with pytest.raises(sftp_handler.RemoteMetadataLimitExceeded): + sftp_handler._TransferMemberBudget(10, metadata_limit=100).consume( + 'ééé' + ) + + budget = sftp_handler._TransferMemberBudget(10, metadata_limit=258) + budget.consume('a') + budget.consume('b') + with pytest.raises(sftp_handler.RemoteMetadataLimitExceeded): + budget.consume('c') + + +def test_remote_exception_text_is_not_reflected_to_file_control_clients( + monkeypatch, +): + from contextlib import contextmanager + + import app.sftp_handler as sftp_handler + + class HostileSFTP: + def mkdir(self, _path): + raise OSError('remote-controlled-' + ('x' * 1024 * 1024)) + + @contextmanager + def fake_session(_identifier): + yield HostileSFTP(), 'session' + + monkeypatch.setattr(sftp_handler, 'sftp_session', fake_session) + + success, error = sftp_handler.create_directory('session', '/safe') + + assert success is False + assert error == 'Remote file operation failed' + assert len(error.encode('utf-8')) <= 512 + assert sftp_handler.public_sftp_error( + sftp_handler.SFTPOperationError('application-authored error') + ) == 'application-authored error' + + +def test_paramiko_directory_parser_rejects_huge_extended_attribute_count(): + import paramiko + from paramiko.message import Message + from paramiko.sftp import ( + CMD_CLOSE, + CMD_HANDLE, + CMD_NAME, + CMD_OPENDIR, + CMD_READDIR, + ) + from paramiko.sftp_attr import SFTPAttributes + import app.sftp_handler as sftp_handler + + class ProtocolSFTP(paramiko.SFTPClient): + def __init__(self): + self.requests = [] + + def _adjust_cwd(self, path): + return path + + def _log(self, *_args): + pass + + def _request(self, command, *args): + self.requests.append(command) + message = Message() + if command == CMD_OPENDIR: + message.add_string(b'directory-handle') + message.rewind() + return CMD_HANDLE, message + if command == CMD_READDIR: + message.add_int(1) + message.add_string('safe.txt') + message.add_string('safe.txt') + message.add_int(SFTPAttributes.FLAG_EXTENDED) + message.add_int(0xffffffff) + message.rewind() + return CMD_NAME, message + if command == CMD_CLOSE: + return 0, message + raise AssertionError(f'unexpected SFTP command {command}') + + sftp = ProtocolSFTP() + + with pytest.raises(sftp_handler.RemoteMetadataLimitExceeded): + list(sftp_handler._iter_paramiko_directory_entries(sftp, '/')) + + assert sftp.requests == [CMD_OPENDIR, CMD_READDIR, CMD_CLOSE] + + +def test_paramiko_directory_parser_closes_on_oversized_handle(monkeypatch): + import config + import paramiko + from paramiko.message import Message + from paramiko.sftp import CMD_HANDLE, CMD_OPENDIR, CMD_READDIR + import app.sftp_handler as sftp_handler + + monkeypatch.setattr(config, 'SFTP_MAX_HANDLE_BYTES', 256) + + class ProtocolSFTP(paramiko.SFTPClient): + def __init__(self): + self.requests = [] + self.channel_closed = False + + def _adjust_cwd(self, path): + return path + + def _log(self, *_args): + pass + + def _request(self, command, *_args): + self.requests.append(command) + if command == CMD_READDIR: + raise AssertionError('oversized handle must not be reflected') + if command != CMD_OPENDIR: + raise AssertionError(f'unexpected SFTP command {command}') + message = Message() + message.add_string(b'x' * 257) + message.rewind() + return CMD_HANDLE, message + + def close(self): + self.channel_closed = True + + sftp = ProtocolSFTP() + + with pytest.raises(sftp_handler.RemoteMetadataLimitExceeded): + list(sftp_handler._iter_paramiko_directory_entries(sftp, '/')) + + assert sftp.requests == [CMD_OPENDIR] + assert sftp.channel_closed is True + + +@pytest.mark.parametrize(('responses', 'expected_error'), [ + pytest.param( + ((),), + 'metadata', + id='empty-name-response', + ), + pytest.param( + (('.', '..'), ('.', '..')), + 'members', + id='repeated-dot-entries', + ), +]) +def test_paramiko_directory_parser_bounds_non_yielding_responses( + monkeypatch, + responses, + expected_error, +): + import paramiko + import config + from paramiko.message import Message + from paramiko.sftp import ( + CMD_CLOSE, + CMD_HANDLE, + CMD_NAME, + CMD_OPENDIR, + CMD_READDIR, + ) + import app.sftp_handler as sftp_handler + + monkeypatch.setattr(config, 'MAX_TRANSFER_MEMBERS', 3) + + class ProtocolSFTP(paramiko.SFTPClient): + def __init__(self): + self.requests = [] + self.responses = iter(responses) + + def _adjust_cwd(self, path): + return path + + def _log(self, *_args): + pass + + def _request(self, command, *args): + self.requests.append(command) + message = Message() + if command == CMD_OPENDIR: + message.add_string(b'directory-handle') + message.rewind() + return CMD_HANDLE, message + if command == CMD_READDIR: + names = next(self.responses) + message.add_int(len(names)) + for name in names: + message.add_string(name) + message.add_string(name) + message.add_int(0) + message.rewind() + return CMD_NAME, message + if command == CMD_CLOSE: + return 0, message + raise AssertionError(f'unexpected SFTP command {command}') + + sftp = ProtocolSFTP() + + error_type = ( + sftp_handler.RemoteMetadataLimitExceeded + if expected_error == 'metadata' + else sftp_handler.TransferMemberLimitExceeded + ) + with pytest.raises(error_type): + list(sftp_handler._iter_paramiko_directory_entries(sftp, '/')) + + assert sftp.requests[-1] == CMD_CLOSE + assert sftp.requests.count(CMD_READDIR) == len(responses) + + +def test_recursive_paramiko_listing_shares_raw_dot_metadata_budget( + monkeypatch, +): + import stat + from types import SimpleNamespace + + import config + import paramiko + from paramiko.message import Message + from paramiko.sftp import ( + CMD_CLOSE, + CMD_HANDLE, + CMD_NAME, + CMD_OPENDIR, + CMD_READDIR, + ) + from paramiko.sftp_attr import SFTPAttributes + import app.sftp_handler as sftp_handler + + # Each directory fits this limit independently, but their combined raw + # dot-entry metadata does not. Recursive traversal must use one budget. + monkeypatch.setattr(config, 'REMOTE_LISTING_MAX_METADATA_BYTES', 550) + + class ProtocolSFTP(paramiko.SFTPClient): + def __init__(self): + self.requests = [] + self.served_handles = set() + + def _adjust_cwd(self, path): + return path + + def _log(self, *_args): + pass + + def _request(self, command, *args): + self.requests.append(command) + message = Message() + if command == CMD_OPENDIR: + message.add_string(args[0].encode('utf-8')) + message.rewind() + return CMD_HANDLE, message + if command == CMD_READDIR: + handle = bytes(args[0]) + if handle in self.served_handles: + raise EOFError() + self.served_handles.add(handle) + names = ( + ('.', '..', 'child') + if handle == b'/' else ('.', '..', 'leaf') + ) + message.add_int(len(names)) + for name in names: + message.add_string(name) + message.add_string(name) + message.add_int(SFTPAttributes.FLAG_PERMISSIONS) + mode = ( + stat.S_IFDIR | 0o700 + if name == 'child' else stat.S_IFREG | 0o600 + ) + message.add_int(mode) + message.rewind() + return CMD_NAME, message + if command == CMD_CLOSE: + return 0, message + raise AssertionError(f'unexpected SFTP command {command}') + + def lstat(self, path): + is_directory = path == '/child' + return SimpleNamespace( + st_mode=( + stat.S_IFDIR | 0o700 + if is_directory else stat.S_IFREG | 0o600 + ), + st_size=0, + ) + + sftp = ProtocolSFTP() + + with pytest.raises(sftp_handler.RemoteMetadataLimitExceeded): + sftp_handler.inspect_remote_tree( + sftp, + '/', + cancel_event=None, + max_bytes=1024, + max_members=20, + ) + + assert sftp.requests.count(CMD_OPENDIR) == 2 + assert sftp.requests.count(CMD_CLOSE) == 2 + + +def test_remote_attribute_extensions_count_toward_aggregate_budget(): + from types import SimpleNamespace + import app.sftp_handler as sftp_handler + + budget = sftp_handler._TransferMemberBudget(10, metadata_limit=140) + entry = SimpleNamespace( + filename='a', + _webssh_extra_metadata_bytes=12, + ) + + with pytest.raises(sftp_handler.RemoteMetadataLimitExceeded): + budget.consume_entry(entry) + + +def test_directory_listing_is_returned_in_bounded_pages(monkeypatch): + import stat + from contextlib import contextmanager + from types import SimpleNamespace + + import config + import app.sftp_handler as sftp_handler + + entries = [ + SimpleNamespace( + filename=name, + st_size=index, + st_mode=stat.S_IFREG | 0o600, + st_mtime=index, + ) + for index, name in enumerate(('one', 'two', 'three')) + ] + + class EntryIterator: + def __init__(self, values): + self.values = iter(values) + self.pulls = 0 + self.closed = False + + def __iter__(self): + return self + + def __next__(self): + value = next(self.values) + self.pulls += 1 + return value + + def close(self): + self.closed = True + + iterator = EntryIterator(entries) + sessions = [] + + class FakeSFTP: + def listdir_iter(self, _path): + return iterator + + @contextmanager + def fake_session(identifier, *, io_lane='control'): + sessions.append((identifier, io_lane, 'open')) + try: + yield FakeSFTP(), 'session' + finally: + sessions.append((identifier, io_lane, 'close')) + + monkeypatch.setattr(sftp_handler, 'sftp_session', fake_session) + monkeypatch.setattr(config, 'REMOTE_FILENAME_MAX_BYTES', 64) + monkeypatch.setattr(config, 'REMOTE_LISTING_MAX_METADATA_BYTES', 4096) + + listing, error = sftp_handler.open_directory_listing('session', '/') + assert error is None + + first, error, has_more = listing.read_page(2) + assert error is None + assert [item['name'] for item in first] == ['one', 'two'] + assert has_more is True + assert iterator.pulls == 3 + + second, error, has_more = listing.read_page(2) + assert error is None + assert [item['name'] for item in second] == ['three'] + assert has_more is False + assert iterator.closed is True + assert sessions == [ + ('session', 'transfer', 'open'), + ('session', 'transfer', 'close'), + ] + + +def test_directory_page_member_budget_is_cumulative(monkeypatch): + import stat + from contextlib import contextmanager + from types import SimpleNamespace + + import config + import app.sftp_handler as sftp_handler + + entries = [ + SimpleNamespace( + filename=f'item-{index}', + st_size=index, + st_mode=stat.S_IFREG | 0o600, + st_mtime=index, + ) + for index in range(4) + ] + + class FakeSFTP: + def listdir_iter(self, _path): + return iter(entries) + + @contextmanager + def fake_session(_identifier, *, io_lane='control'): + assert io_lane == 'transfer' + yield FakeSFTP(), 'session' + + monkeypatch.setattr(sftp_handler, 'sftp_session', fake_session) + monkeypatch.setattr(config, 'MAX_TRANSFER_MEMBERS', 3) + monkeypatch.setattr(config, 'REMOTE_FILENAME_MAX_BYTES', 64) + monkeypatch.setattr(config, 'REMOTE_LISTING_MAX_METADATA_BYTES', 4096) + + listing, error = sftp_handler.open_directory_listing('session', '/') + assert error is None + first, error, has_more = listing.read_page(2) + assert error is None + assert len(first) == 2 + assert has_more is True + + second, error, has_more = listing.read_page(2) + + assert second is None + assert error == 'Directory exceeds configured member limit' + assert has_more is False + + def test_transfer_lane_owns_and_closes_a_fresh_sftp_channel(monkeypatch): import app.sftp_handler as sftp_handler diff --git a/tests/test_sftp_request_correlation.py b/tests/test_sftp_request_correlation.py index f91bc384..f7323c08 100644 --- a/tests/test_sftp_request_correlation.py +++ b/tests/test_sftp_request_correlation.py @@ -22,10 +22,14 @@ def _capture(monkeypatch): def test_list_directory_mirrors_request_identity(monkeypatch): emitted, user = _capture(monkeypatch) + calls = [] monkeypatch.setattr( socket_events.file_service, - 'list_directory', - lambda source_id, *, user_id, path: ([{'name': 'config.yml'}], None), + 'list_directory_page', + lambda source_id, **kwargs: ( + calls.append((source_id, kwargs)) + or ([{'name': 'config.yml'}], None, None) + ), ) socket_events.handle_list_directory.__wrapped__({ @@ -34,10 +38,19 @@ def test_list_directory_mirrors_request_identity(monkeypatch): 'request_id': 'left:directory:4', }, current_user=user) + assert calls == [('sftp-session:session-a', { + 'user_id': 7, + 'path': '/srv/current', + 'cursor': 0, + 'client_id': None, + 'request_id': 'left:directory:4', + })] assert emitted == [('directory_listing', { 'source_id': 'sftp-session:session-a', 'path': '/srv/current', 'files': [{'name': 'config.yml'}], + 'cursor': 0, + 'next_cursor': None, 'request_id': 'left:directory:4', })] @@ -46,8 +59,10 @@ def test_list_directory_error_is_correlated(monkeypatch): emitted, user = _capture(monkeypatch) monkeypatch.setattr( socket_events.file_service, - 'list_directory', - lambda _source_id, *, user_id, path: (None, 'permission denied'), + 'list_directory_page', + lambda _source_id, *, user_id, path, cursor, client_id, request_id: ( + None, 'permission denied', None + ), ) socket_events.handle_list_directory.__wrapped__({ @@ -65,6 +80,231 @@ def test_list_directory_error_is_correlated(monkeypatch): })] +def test_list_directory_continuation_error_echoes_opaque_cursor(monkeypatch): + emitted, user = _capture(monkeypatch) + token = f"v1.abcdefghijklmnop.2.{'b' * 32}" + monkeypatch.setattr( + socket_events.file_service, + 'list_directory_page', + lambda _source_id, *, user_id, path, cursor, client_id, request_id: ( + None, 'listing expired', None + ), + ) + + socket_events.handle_list_directory.__wrapped__({ + 'source_id': 'sftp-session:session-a', + 'remote_path': '/srv', + 'request_id': 'move-picker:directory:6', + 'cursor': token, + }, current_user=user) + + assert emitted == [('error', { + 'error': 'Failed to list directory: listing expired', + 'operation': 'list_directory', + 'source_id': 'sftp-session:session-a', + 'path': '/srv', + 'request_id': 'move-picker:directory:6', + 'cursor': token, + })] + + +def test_cancel_directory_listing_is_exactly_socket_scoped_and_silent( + monkeypatch, +): + emitted, user = _capture(monkeypatch) + calls = [] + token = f"v1.abcdefghijklmnop.2.{'b' * 32}" + monkeypatch.setattr( + socket_events, + 'request', + SimpleNamespace(sid='socket-a'), + ) + monkeypatch.setattr( + socket_events.file_service, + 'cancel_directory_snapshot', + lambda cursor, **kwargs: calls.append((cursor, kwargs)) or True, + ) + + result = socket_events.handle_cancel_directory_listing.__wrapped__({ + 'source_id': 'sftp-session:session-a', + 'request_id': 'directory:cancel:7', + 'cursor': token, + }, current_user=user) + + assert result == {'success': True} + assert emitted == [] + assert calls == [(token, { + 'user_id': 7, + 'source_id': 'sftp-session:session-a', + 'client_id': 'socket-a', + })] + + +def test_cancel_page_zero_listing_is_exactly_request_and_socket_scoped( + monkeypatch, +): + emitted, user = _capture(monkeypatch) + calls = [] + monkeypatch.setattr( + socket_events, + 'request', + SimpleNamespace(sid='socket-a'), + ) + monkeypatch.setattr( + socket_events.file_service, + 'cancel_directory_request', + lambda request_id, **kwargs: ( + calls.append((request_id, kwargs)) or True + ), + ) + + result = socket_events.handle_cancel_directory_listing.__wrapped__({ + 'source_id': 'sftp-session:session-a', + 'request_id': 'directory:cancel:8', + 'listing_request_id': 'left:directory:7', + }, current_user=user) + + assert result == {'success': True} + assert emitted == [] + assert calls == [('left:directory:7', { + 'user_id': 7, + 'source_id': 'sftp-session:session-a', + 'client_id': 'socket-a', + })] + + +@pytest.mark.parametrize('listing_request_id', ( + '', + 'invalid request id', + 'x' * 129, +)) +def test_cancel_page_zero_listing_rejects_invalid_request_authority( + monkeypatch, + listing_request_id, +): + _emitted, user = _capture(monkeypatch) + calls = [] + monkeypatch.setattr( + socket_events.file_service, + 'cancel_directory_request', + lambda *args, **kwargs: calls.append((args, kwargs)) or True, + ) + + result = socket_events.handle_cancel_directory_listing.__wrapped__({ + 'source_id': 'sftp-session:session-a', + 'request_id': 'directory:cancel:9', + 'listing_request_id': listing_request_id, + }, current_user=user) + + assert result == {'success': True} + assert calls == [] + + +def test_cancel_directory_listing_does_not_reveal_invalid_or_missing_state( + monkeypatch, +): + _emitted, user = _capture(monkeypatch) + calls = [] + monkeypatch.setattr( + socket_events.file_service, + 'cancel_directory_snapshot', + lambda *args, **kwargs: calls.append((args, kwargs)) or False, + ) + + invalid = socket_events.handle_cancel_directory_listing.__wrapped__({ + 'source_id': 'sftp-session:session-a', + 'request_id': 'directory:cancel:8', + 'cursor': 0, + }, current_user=user) + token = f"v1.abcdefghijklmnop.2.{'c' * 32}" + missing = socket_events.handle_cancel_directory_listing.__wrapped__({ + 'source_id': 'sftp-session:session-a', + 'request_id': 'directory:cancel:9', + 'cursor': token, + }, current_user=user) + + assert invalid == {'success': True} + assert missing == {'success': True} + assert len(calls) == 1 + + +def test_socket_disconnect_discards_only_its_directory_snapshots(monkeypatch): + discarded = [] + + class Query: + def filter_by(self, **_kwargs): + return self + + def delete(self): + return 1 + + def count(self): + return 1 + + monkeypatch.setattr( + socket_events, + 'request', + SimpleNamespace(sid='socket-a'), + ) + monkeypatch.setattr( + socket_events.ssh_output_flow, + 'release_socket', + lambda _socket_id: None, + ) + monkeypatch.setattr( + socket_events, + '_cancel_ssh_banner_prompts_for_socket', + lambda _socket_id: None, + ) + monkeypatch.setattr( + socket_events.socket_capacity, + 'release', + lambda _socket_id: 7, + ) + monkeypatch.setattr( + socket_events.socket_capacity, + 'count_for_user', + lambda _user_id: 1, + ) + monkeypatch.setattr( + socket_events, + 'get_user_from_socket', + lambda _socket_id: None, + ) + monkeypatch.setattr( + socket_events, + '_cancel_smb_attempts_for_socket', + lambda _user_id, _socket_id: None, + ) + monkeypatch.setattr( + socket_events.transfer_manager, + 'cancel_all_for_socket', + lambda _user_id, _socket_id: None, + ) + monkeypatch.setattr( + socket_events.file_service, + 'discard_directory_snapshots', + lambda **kwargs: discarded.append(kwargs), + ) + monkeypatch.setattr( + socket_events, + 'SocketSession', + SimpleNamespace(query=Query()), + ) + monkeypatch.setattr( + socket_events, + 'db', + SimpleNamespace(session=SimpleNamespace( + commit=lambda: None, + rollback=lambda: None, + )), + ) + + socket_events.handle_disconnect() + + assert discarded == [{'user_id': 7, 'client_id': 'socket-a'}] + + def test_home_directory_mirrors_request_identity(monkeypatch): emitted, user = _capture(monkeypatch) monkeypatch.setattr( diff --git a/tests/test_smb_backend.py b/tests/test_smb_backend.py index 504e9d38..f3fcf3d6 100644 --- a/tests/test_smb_backend.py +++ b/tests/test_smb_backend.py @@ -12,7 +12,12 @@ ResolvedFileSource, make_source_id, ) -from app.file_backend import FileReaderLease, FileWriteOutcome +from app.file_backend import ( + FileOperationCancelled, + FileReaderLease, + FileSourceChanged, + FileWriteOutcome, +) from app.smb_backend import ( FileConflict, NonAtomicOverwriteRequired, @@ -24,22 +29,49 @@ class _Stat: - def __init__(self, *, size=0, mode=0o100644, attributes=0, modified=10): + def __init__( + self, + *, + size=0, + mode=0o100644, + attributes=0, + modified=10, + identity=1, + links=1, + ): self.st_size = size self.st_mode = mode self.st_file_attributes = attributes self.st_mtime = modified + self.st_ino = identity + self.st_nlink = links class _Entry: - def __init__(self, name, *, directory=False, reparse=False, size=0): + def __init__( + self, + name, + *, + directory=False, + reparse=False, + size=0, + identity=1, + ): self.name = name self._directory = directory self._reparse = reparse + attributes = (0x10 if directory else 0) | (0x400 if reparse else 0) self._stat = _Stat( size=size, mode=0o040755 if directory else 0o100644, - attributes=(0x400 if reparse else 0), + attributes=attributes, + identity=identity, + ) + self.smb_info = SimpleNamespace( + file_id=identity, + file_attributes=attributes, + end_of_file=size, + last_write_time=10, ) def stat(self, follow_symlinks=True): @@ -55,9 +87,20 @@ def is_symlink(self): class _Iterator: - def __init__(self, entries): + def __init__( + self, + entries, + *, + identity_chain=(), + children=None, + child_identities=None, + ): self._entries = iter(entries) self.closed = False + self.identity_chain = identity_chain + self.children = dict(children or {}) + self.child_identities = dict(child_identities or {}) + self.child_open_calls = [] def __iter__(self): return self @@ -65,6 +108,20 @@ def __iter__(self): def __next__(self): return next(self._entries) + def open_child_directory(self, entry): + self.child_open_calls.append(entry.name) + expected = entry.smb_info.file_id + actual = self.child_identities.get(entry.name, expected) + if actual != expected: + raise SMBProtocolError('CONFLICT') + child = self.children.get(entry.name) + if child is None: + raise AssertionError(f'unexpected child traversal: {entry.name}') + if callable(child): + child = child(entry) + child.identity_chain = (*self.identity_chain, actual) + return child + def close(self): self.closed = True @@ -96,19 +153,94 @@ def __init__(self): def invoke(self, name, *args, **kwargs): self.calls.append((name, args, kwargs)) + if name == 'open_handle_matches_path_verified': + handle, candidate = args + return getattr(handle, 'path', None) == candidate + if name in { + 'scandir_verified', + 'open_file_move_verified', + 'open_file_verified', + 'stat_verified', + 'delete_verified', + }: + expected = kwargs.get('expected_identities') + if expected is not None and tuple(expected) != self._identity_chain( + args[0] + ): + raise SMBProtocolError('CONFLICT') response = self.responses.get(name) + if response is None and name == 'rename_open_handle_verified': + response = self.responses.get( + 'replace' if kwargs.get('replace') else 'rename' + ) if response is None: - response = self.responses.get({ - 'open_file_no_follow': 'open_file', - 'scandir_no_follow': 'scandir', - 'mkdir_no_follow': 'mkdir', - }.get(name, '')) + aliases = { + 'open_file_no_follow': ('open_file',), + 'open_file_verified': ( + 'open_file_no_follow', + 'open_file', + ), + 'create_file_move_verified': ( + 'open_file_no_follow', + 'open_file', + ), + 'open_file_move_verified': ( + 'open_file_verified', + 'open_file_no_follow', + 'open_file', + ), + 'scandir_no_follow': ('scandir',), + 'scandir_verified': ('scandir',), + 'stat_verified': ('stat',), + 'mkdir_no_follow': ('mkdir',), + }.get(name, ()) + for alias in aliases: + response = self.responses.get(alias) + if response is not None: + break if isinstance(response, Exception): raise response if callable(response): - return response(*args, **kwargs) + if name == 'stat_verified' and 'stat_verified' not in self.responses: + kwargs.setdefault('follow_symlinks', False) + response = response(*args, **kwargs) + if name == 'stat_verified' and response is not None: + if hasattr(response, 'identity_chain'): + return response + chain = self._identity_chain(args[0]) + if chain: + chain = (*chain[:-1], response.st_ino) + return SimpleNamespace( + file_id=response.st_ino, + file_attributes=response.st_file_attributes, + end_of_file=response.st_size, + last_write_time=response.st_mtime, + number_of_links=response.st_nlink, + identity_chain=chain, + ) + if name == 'scandir_verified' and response is not None: + response.identity_chain = self._identity_chain(args[0]) + if name in {'delete_verified', 'delete_open_handle_verified'}: + return response + if name == 'create_file_move_verified' and response is not None: + response.path = args[0] + if ( + name == 'rename_open_handle_verified' + and response is None + and hasattr(args[0], 'path') + ): + args[0].path = args[1] return response + def _identity_chain(self, path): + parts = path[2:].split('\\')[2:] + configured = self.responses.get('pinned_identities', {}) + root = '\\\\' + '\\'.join(path[2:].split('\\')[:2]) + return tuple( + configured.get(root + '\\' + '\\'.join(parts[:index]), 1) + for index in range(1, len(parts) + 1) + ) + def inspect_directory_access(self, path): self.calls.append(('inspect_directory_access', (path,), {})) response = self.responses.get('inspect_directory_access') @@ -116,6 +248,37 @@ def inspect_directory_access(self, path): raise response return response + def _pinned_identities(self, paths, expected_identities): + configured = self.responses.get('pinned_identities', {}) + identities = { + path: configured.get(path, 1) + for path in paths + } + for path, expected in (expected_identities or {}).items(): + if identities.get(path) != expected: + raise SMBProtocolError('CONFLICT') + return identities + + @contextmanager + def pin_directories(self, paths, *, expected_identities=None): + paths = tuple(paths) + self.calls.append(( + 'pin_directories', + (paths,), + {'expected_identities': expected_identities or {}}, + )) + yield self._pinned_identities(paths, expected_identities) + + @contextmanager + def pin_mutation_ancestors(self, paths, *, expected_identities=None): + paths = tuple(paths) + self.calls.append(( + 'pin_mutation_ancestors', + (paths,), + {'expected_identities': expected_identities or {}}, + )) + yield self._pinned_identities(paths, expected_identities) + def _fixture(): session = _Session() @@ -161,42 +324,97 @@ def __init__(self, original=b'old'): def invoke(self, name, *args, **kwargs): self.calls.append((name, args, kwargs)) - path = args[0] if args else None + subject = args[0] if args else None + path = subject if isinstance(subject, str) else getattr( + subject, 'path', None + ) failure = self.failures.get((name, path), self.failures.get(name)) if failure is not None: raise failure - if name == 'stat': + if name in {'stat', 'stat_verified'}: if path not in self.files: raise FileNotFoundError(path) - return _Stat(size=len(self.files[path])) - if name == 'open_file_no_follow': - mode = kwargs['mode'] + result = _Stat(size=len(self.files[path])) + if name == 'stat': + return result + return SimpleNamespace( + file_id=1, + file_attributes=0, + end_of_file=result.st_size, + last_write_time=result.st_mtime, + number_of_links=1, + identity_chain=self._identity_chain(path), + ) + if name in { + 'open_file_no_follow', + 'open_file_verified', + 'open_file_move_verified', + 'create_file_move_verified', + }: + mode = kwargs.get('mode', 'rb') + if name == 'create_file_move_verified': + mode = 'xb' self.open_modes.append((path, mode)) if mode == 'rb': if path not in self.files: raise FileNotFoundError(path) + if name == 'open_file_move_verified': + handle = self._Handle(self, path, writable=False) + return handle, SimpleNamespace( + file_id=1, + file_attributes=0, + end_of_file=len(self.files[path]), + last_write_time=10, + number_of_links=1, + identity_chain=self._identity_chain(path), + ) return _Readable(self.files[path]) if mode != 'xb': raise AssertionError(f'unsafe editor mode: {mode}') if path in self.files: raise FileExistsError(path) - session = self - - class _StoredWrite(_Writable): - def close(self): - if not self.closed: - session.files[path] = self.getvalue() - super().close() - - return _StoredWrite() - if name in {'rename', 'replace'}: + self.files[path] = b'' + return self._Handle(self, path, writable=True) + if name in {'rename', 'replace', 'rename_verified'}: old_path, new_path = args if old_path not in self.files: raise FileNotFoundError(old_path) - if name == 'rename' and new_path in self.files: + replacing = name == 'replace' or kwargs.get('replace') is True + if not replacing and new_path in self.files: raise FileExistsError(new_path) self.files[new_path] = self.files.pop(old_path) return None + if name == 'rename_open_handle_verified': + handle, new_path = args + if handle.closed: + raise AssertionError('closed handle was renamed') + if handle.path not in self.files: + raise FileNotFoundError(handle.path) + if not kwargs.get('replace') and new_path in self.files: + raise FileExistsError(new_path) + data = ( + handle.getvalue() + if handle.writable + else self.files[handle.path] + ) + self.files.pop(handle.path) + self.files[new_path] = data + handle.path = new_path + return None + if name == 'open_handle_matches_path_verified': + handle, candidate = args + if handle.closed: + raise AssertionError('closed handle was queried') + return handle.path == candidate + if name == 'delete_open_handle_verified': + handle = args[0] + if handle.closed: + raise AssertionError('closed handle was deleted') + if handle.path not in self.files: + raise FileNotFoundError(handle.path) + handle.delete_pending = True + self.files.pop(handle.path) + return None if name == 'remove': if path not in self.files: raise FileNotFoundError(path) @@ -204,6 +422,19 @@ def close(self): return None raise AssertionError(f'unexpected SMB operation: {name}') + class _Handle(BytesIO): + def __init__(self, session, path, *, writable): + super().__init__(session.files[path]) + self.session = session + self.path = path + self.writable = writable + self.delete_pending = False + + def close(self): + if not self.closed and self.writable and not self.delete_pending: + self.session.files[self.path] = self.getvalue() + super().close() + def _stateful_fixture(original=b'old'): backend, source, _session = _fixture() @@ -230,6 +461,125 @@ def test_listing_closes_iterator_and_marks_reparse_entries_unfollowable(): assert listing[1]['is_symlink'] is True +def test_listing_preserves_valid_dollar_in_path_component(): + backend, source, session = _fixture() + iterator = _Iterator([_Entry('budget$.xlsx', identity=17)]) + session.responses['scandir'] = iterator + + listing, error = backend.list_directory(source, '/reports') + + assert error is None + assert listing == [{ + 'name': 'budget$.xlsx', + 'path': '/reports/budget$.xlsx', + 'size': 0, + 'mode': 0o100666, + 'is_dir': False, + 'is_symlink': False, + 'modified': 10, + }] + assert iterator.closed is True + + +def test_overdeep_path_is_rejected_before_any_smb_operation(): + backend, source, session = _fixture() + overdeep = '/' + '/'.join('a' for _ in range(129)) + + listing, error = backend.list_directory(source, overdeep) + + assert listing is None + assert error == 'Invalid path' + assert session.calls == [] + + +def test_paged_listing_reuses_one_bounded_scandir_iterator(): + backend, source, session = _fixture() + iterator = _Iterator([ + _Entry('one'), + _Entry('two'), + _Entry('three'), + ]) + session.responses['scandir'] = iterator + + listing, error = backend.open_directory_listing(source, '/') + assert error is None + first, error, has_more = listing.read_page(2) + assert error is None + assert [item['name'] for item in first] == ['one', 'two'] + assert has_more is True + second, error, has_more = listing.read_page(2) + + assert error is None + assert [item['name'] for item in second] == ['three'] + assert has_more is False + assert iterator.closed is True + assert [call[0] for call in session.calls].count('scandir_verified') == 1 + + +def test_paged_listing_keeps_verified_scanner_open_until_exhausted(): + backend, source, session = _fixture() + observed = [] + + class LazyIterator(_Iterator): + def __next__(self): + observed.append(not self.closed) + return super().__next__() + + session.responses['scandir'] = LazyIterator([_Entry('one', identity=31)]) + + listing, error = backend.open_directory_listing(source, '/safe') + + assert error is None + assert observed == [] + page, error, has_more = listing.read_page(10) + assert error is None + assert [item['name'] for item in page] == ['one'] + assert has_more is False + assert observed == [True, True] + assert listing._iterator is None + + +@pytest.mark.parametrize('missing_identity', [False, True]) +def test_listing_fails_closed_when_server_identity_is_unavailable( + missing_identity, +): + backend, source, session = _fixture() + entry = _Entry('unknown.txt', identity=0) + if missing_identity: + del entry.smb_info.file_id + iterator = _Iterator([entry]) + session.responses['scandir'] = iterator + + listing, error = backend.list_directory(source, '/') + + assert listing is None + assert error == 'SMB object identity is unavailable' + assert iterator.closed is True + + +def test_paged_listing_member_budget_is_cumulative(monkeypatch): + import config + + backend, source, session = _fixture() + iterator = _Iterator([_Entry(str(index)) for index in range(4)]) + session.responses['scandir'] = iterator + monkeypatch.setattr(config, 'MAX_TRANSFER_MEMBERS', 3) + + listing, error = backend.open_directory_listing(source, '/') + assert error is None + first, error, has_more = listing.read_page(2) + assert error is None + assert len(first) == 2 + assert has_more is True + + second, error, has_more = listing.read_page(2) + + assert second is None + assert error == 'Directory exceeds configured member limit' + assert has_more is False + assert iterator.closed is True + + def test_directory_access_inspection_uses_the_owned_share_confined_source(): backend, source, session = _fixture() session.responses['inspect_directory_access'] = { @@ -261,6 +611,20 @@ def test_directory_access_inspection_preserves_protocol_failure(): assert exc.value.public_code == 'PERMISSION_DENIED' +def test_non_root_access_inspection_uses_verified_session_boundary(): + backend, source, session = _fixture() + + def inspect(path): + assert path == r'\\10.0.0.8\Docs\safe\nested' + return {'list': 'granted'} + + session.inspect_directory_access = inspect + + assert backend.inspect_directory_access(source, '/safe/nested') == { + 'list': 'granted' + } + + def test_stat_or_raise_preserves_protocol_failure_for_transfer_boundaries(): backend, source, session = _fixture() session.responses['stat'] = SMBProtocolError('PERMISSION_DENIED') @@ -271,6 +635,23 @@ def test_stat_or_raise_preserves_protocol_failure_for_transfer_boundaries(): assert exc.value.public_code == 'PERMISSION_DENIED' +def test_stat_uses_verified_protocol_metadata(): + backend, source, session = _fixture() + + def stat(path, *, follow_symlinks): + assert follow_symlinks is False + assert path == r'\\10.0.0.8\Docs\safe\file.txt' + return _Stat(size=4) + + session.responses['stat'] = stat + + result = backend.stat_or_raise(source, '/safe/file.txt') + + assert result['size'] == 4 + assert result['_smb_identity_chain'] == (1, 1) + assert session.calls[0][0] == 'stat_verified' + + def test_typed_directory_mutations_preserve_protocol_failure(): backend, source, session = _fixture() session.responses['stat'] = _Stat(mode=0o040755, attributes=0x10) @@ -283,7 +664,7 @@ def test_typed_directory_mutations_preserve_protocol_failure(): assert exc.value.public_code == 'PERMISSION_DENIED' -def test_listing_and_recursive_traversal_use_no_follow_directory_opens(): +def test_listing_and_recursive_traversal_use_verified_directory_handles(): backend, source, session = _fixture() session.responses['scandir'] = _Iterator([]) session.responses['scandir_no_follow'] = _Iterator([]) @@ -292,7 +673,7 @@ def test_listing_and_recursive_traversal_use_no_follow_directory_opens(): assert error is None assert listing == [] - assert session.calls[0][0] == 'scandir_no_follow' + assert any(call[0] == 'scandir_verified' for call in session.calls) session.calls.clear() list(backend.iter_tree( @@ -301,7 +682,7 @@ def test_listing_and_recursive_traversal_use_no_follow_directory_opens(): budget=_MemberBudget(1), cancel_event=Event(), )) - assert session.calls[0][0] == 'scandir_no_follow' + assert any(call[0] == 'scandir_verified' for call in session.calls) def test_listing_rejects_unsafe_server_supplied_name_and_closes_iterator(): @@ -324,11 +705,21 @@ def test_stat_never_follows_or_accepts_reparse_points(): assert result is None assert error == 'Reparse points are not supported' - assert session.calls[0][2]['follow_symlinks'] is False + assert session.calls[0][0] == 'stat_verified' def test_mutations_reject_reparse_ancestors_before_side_effects(): - mutating_names = {'mkdir', 'rename', 'replace', 'remove', 'rmdir'} + mutating_names = { + 'create_file_move_verified', + 'delete_open_handle_verified', + 'mkdir', + 'remove', + 'rename', + 'rename_open_handle_verified', + 'rename_verified', + 'replace', + 'rmdir', + } def fixture_with_reparse_ancestor(): backend, source, session = _fixture() @@ -349,12 +740,9 @@ def path_stat(path, *, follow_symlinks): assert not any(call[0] in mutating_names for call in session.calls) backend, source, session = fixture_with_reparse_ancestor() - success, error = backend.rename(source, '/link/old', '/safe/new') - assert success is False - assert error == 'Reparse points are not supported' - assert not any(call[0] in mutating_names for call in session.calls) - - backend, source, session = fixture_with_reparse_ancestor() + session.responses['stat_verified'] = SMBProtocolError( + 'REPARSE_POINT_REJECTED' + ) success, error = backend.delete( source, '/link/file', @@ -366,6 +754,34 @@ def path_stat(path, *, follow_symlinks): assert error == 'Reparse points are not supported' assert not any(call[0] in mutating_names for call in session.calls) + backend, source, session = fixture_with_reparse_ancestor() + success, error = backend.rename(source, '/link/old', '/safe/new') + assert success is False + assert error == 'Reparse points are not supported' + assert not any(call[0] in mutating_names for call in session.calls) + + +def test_rename_uses_verified_source_handle_operation(): + backend, source, session = _fixture() + session.responses['stat'] = _Stat() + + success, error = backend.rename( + source, + '/old.txt', + '/renamed.txt', + ) + + assert success is True + assert error is None + rename_call = next( + call for call in session.calls if call[0] == 'rename_verified' + ) + assert rename_call[1] == ( + r'\\10.0.0.8\Docs\old.txt', + r'\\10.0.0.8\Docs\renamed.txt', + ) + assert rename_call[2] == {'replace': False} + def test_atomic_writer_rejects_reparse_parent_before_creating_temp_file(): backend, source, session = _fixture() @@ -390,7 +806,11 @@ def path_stat(path, *, follow_symlinks): remote.write(b'new') assert all( - name not in {'open_file', 'open_file_no_follow'} + name not in { + 'create_file_move_verified', + 'open_file', + 'open_file_no_follow', + } for name, _args, _kwargs in session.calls ) @@ -411,10 +831,16 @@ def test_atomic_replace_never_predeletes_existing_target(): remote.write(b'new') names = [name for name, _args, _kwargs in session.calls] - assert 'remove' in names # generated temp only - assert names.index('replace') < names.index('remove') - removed = next(args[0] for name, args, _kwargs in session.calls if name == 'remove') - assert removed != r'\\10.0.0.8\Docs\report.txt' + assert 'delete_open_handle_verified' in names + assert names.index('rename_open_handle_verified') < names.index( + 'delete_open_handle_verified' + ) + deleted = next( + args[0] + for name, args, _kwargs in session.calls + if name == 'delete_open_handle_verified' + ) + assert deleted is writer def test_editor_read_returns_revision_of_exact_remote_bytes(): @@ -480,6 +906,19 @@ def test_recoverable_editor_swap_never_opens_destination_for_write(): path != session.destination or mode == 'rb' for path, mode in session.open_modes ) + renames = [ + call for call in session.calls + if call[0] == 'rename_open_handle_verified' + ] + deletes = [ + call for call in session.calls + if call[0] == 'delete_open_handle_verified' + ] + assert len(renames) == 2 + destination_handle = renames[0][1][0] + temporary_handle = renames[1][1][0] + assert destination_handle is not temporary_handle + assert deletes[-1][1][0] is destination_handle def test_recoverable_editor_swap_rolls_back_when_install_fails(): @@ -487,8 +926,11 @@ def test_recoverable_editor_swap_rolls_back_when_install_fails(): original_revision = hashlib.sha256(b'old').hexdigest() def fail_temp_install(name, *args, **kwargs): - path = args[0] if args else None - if name == 'rename' and path and '.webssh-write-' in path: + path = getattr(args[0], 'path', '') if args else '' + if ( + name == 'rename_open_handle_verified' + and '.webssh-write-' in path + ): raise SMBProtocolError('PERMISSION_DENIED') return _StatefulSMBSession.invoke(session, name, *args, **kwargs) @@ -515,8 +957,8 @@ def test_recoverable_editor_swap_preserves_safe_artifacts_when_rollback_fails(): original_revision = hashlib.sha256(b'old').hexdigest() def fail_install_and_rollback(name, *args, **kwargs): - old_path = args[0] if args else '' - if name == 'rename' and ( + old_path = getattr(args[0], 'path', '') if args else '' + if name == 'rename_open_handle_verified' and ( '.webssh-write-' in old_path or '.webssh-recovery-' in old_path ): raise SMBProtocolError('PERMISSION_DENIED') @@ -549,8 +991,11 @@ def test_recoverable_editor_swap_reports_retained_backup_after_cleanup_failure() original_invoke = session.invoke def fail_backup_cleanup(name, *args, **kwargs): - path = args[0] if args else '' - if name == 'remove' and '.webssh-recovery-' in path: + path = getattr(args[0], 'path', '') if args else '' + if ( + name == 'delete_open_handle_verified' + and '.webssh-recovery-' in path + ): raise SMBProtocolError('PERMISSION_DENIED') return original_invoke(name, *args, **kwargs) @@ -611,7 +1056,7 @@ def test_legacy_non_atomic_consent_never_truncates_the_destination(): ) -def test_non_permission_replace_failure_never_uses_direct_overwrite(): +def test_default_editor_requires_recoverable_swap_without_remote_mutation(): backend, source, session = _stateful_fixture() session.failures['replace'] = SMBProtocolError('CONFLICT') @@ -626,7 +1071,14 @@ def test_non_permission_replace_failure_never_uses_direct_overwrite(): ) assert outcome.success is False - assert outcome.error == 'Atomic replacement is unavailable' + assert outcome.code == 'SMB_RECOVERABLE_REPLACE_REQUIRED' + assert not any( + name in { + 'create_file_move_verified', + 'rename_open_handle_verified', + } + for name, _args, _kwargs in session.calls + ) assert all( mode != 'wb' for _path, mode in session.open_modes @@ -641,7 +1093,9 @@ def test_non_permission_replace_failure_never_uses_direct_overwrite(): ]) def test_atomic_replace_preserves_actionable_non_conflict_failure(public_code): backend, source, session = _stateful_fixture() - session.failures['replace'] = SMBProtocolError(public_code) + session.failures['rename_open_handle_verified'] = SMBProtocolError( + public_code + ) with pytest.raises(SMBProtocolError) as caught: with backend.open_atomic_writer( @@ -656,12 +1110,485 @@ def test_atomic_replace_preserves_actionable_non_conflict_failure(public_code): assert session.files == {session.destination: b'old'} +def test_atomic_replace_reconciles_a_committed_rename_with_lost_response(): + backend, source, session = _stateful_fixture() + original_invoke = session.invoke + + def commit_then_timeout(name, *args, **kwargs): + result = original_invoke(name, *args, **kwargs) + if name == 'rename_open_handle_verified': + raise SMBProtocolError('TIMEOUT') + return result + + session.invoke = commit_then_timeout + + with backend.open_atomic_writer( + source, + '/report.txt', + replace=True, + cancel_event=None, + ) as remote_file: + remote_file.write(b'new') + + assert session.files == {session.destination: b'new'} + assert not any( + name == 'delete_open_handle_verified' + for name, _args, _kwargs in session.calls + ) + + +def test_atomic_replace_does_not_fail_after_committed_close_response_loss( + monkeypatch, +): + backend, source, session = _stateful_fixture() + original_close = session._Handle.close + + def close_then_timeout(remote_file): + original_close(remote_file) + raise TimeoutError('close response lost after handle closed') + + monkeypatch.setattr(session._Handle, 'close', close_then_timeout) + + with backend.open_atomic_writer( + source, + '/report.txt', + replace=True, + cancel_event=None, + ) as remote_file: + remote_file.write(b'new') + + assert session.files == {session.destination: b'new'} + + +def test_atomic_replace_taints_session_when_close_fails_before_send( + monkeypatch, +): + backend, source, session = _stateful_fixture() + original_invoke = session.invoke + original_close = session._Handle.close + opened = [] + + def capture_handles(name, *args, **kwargs): + result = original_invoke(name, *args, **kwargs) + if name == 'create_file_move_verified': + opened.append(result) + return result + + def close_session(): + session._closed = True + for remote_file in opened: + original_close(remote_file) + return True + + session.invoke = capture_handles + session._closed = False + session.close = close_session + monkeypatch.setattr( + session._Handle, + 'close', + lambda _remote_file: (_ for _ in ()).throw( + TimeoutError('close request was not sent') + ), + ) + + with backend.open_atomic_writer( + source, + '/report.txt', + replace=True, + cancel_event=None, + ) as remote_file: + remote_file.write(b'new') + + assert session._closed is True + assert all(remote_file.closed for remote_file in opened) + assert session.files == {session.destination: b'new'} + + +def test_atomic_replace_taints_session_and_propagates_close_interrupt( + monkeypatch, +): + backend, source, session = _stateful_fixture() + original_invoke = session.invoke + original_close = session._Handle.close + opened = [] + + def capture_handles(name, *args, **kwargs): + result = original_invoke(name, *args, **kwargs) + if name == 'create_file_move_verified': + opened.append(result) + return result + + def close_session(): + session._closed = True + for remote_file in opened: + original_close(remote_file) + + session.invoke = capture_handles + session._closed = False + session.close = close_session + monkeypatch.setattr( + session._Handle, + 'close', + lambda _remote_file: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + + with pytest.raises(KeyboardInterrupt): + with backend.open_atomic_writer( + source, + '/report.txt', + replace=True, + cancel_event=None, + ) as remote_file: + remote_file.write(b'new') + + assert session._closed is True + assert all(remote_file.closed for remote_file in opened) + assert session.files == {session.destination: b'new'} + + +def test_recoverable_editor_taints_session_when_handle_close_is_unsent( + monkeypatch, +): + backend, source, session = _stateful_fixture() + original_invoke = session.invoke + original_close = session._Handle.close + opened = [] + + def capture_handles(name, *args, **kwargs): + result = original_invoke(name, *args, **kwargs) + if name == 'open_file_move_verified': + opened.append(result[0]) + elif name == 'create_file_move_verified': + opened.append(result) + return result + + def close_session(): + session._closed = True + for remote_file in opened: + if not remote_file.closed: + original_close(remote_file) + return True + + session.invoke = capture_handles + session._closed = False + session.close = close_session + monkeypatch.setattr( + session._Handle, + 'close', + lambda _remote_file: (_ for _ in ()).throw( + TimeoutError('close request was not sent') + ), + ) + + outcome = backend.write_file_text( + source, + '/report.txt', + 'new', + encoding='utf-8', + newline='lf', + expected_revision=hashlib.sha256(b'old').hexdigest(), + replace_strategy='recoverable_swap', + ) + + assert outcome.success is True + assert session._closed is True + assert all(remote_file.closed for remote_file in opened) + assert session.files == {session.destination: b'new'} + + +def test_recoverable_editor_taints_session_and_propagates_close_interrupt( + monkeypatch, +): + backend, source, session = _stateful_fixture() + original_invoke = session.invoke + original_close = session._Handle.close + opened = [] + + def capture_handles(name, *args, **kwargs): + result = original_invoke(name, *args, **kwargs) + if name == 'open_file_move_verified': + opened.append(result[0]) + elif name == 'create_file_move_verified': + opened.append(result) + return result + + def close_session(): + session._closed = True + for remote_file in opened: + if not remote_file.closed: + original_close(remote_file) + + session.invoke = capture_handles + session._closed = False + session.close = close_session + monkeypatch.setattr( + session._Handle, + 'close', + lambda _remote_file: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + + with pytest.raises(KeyboardInterrupt): + backend.write_file_text( + source, + '/report.txt', + 'new', + encoding='utf-8', + newline='lf', + expected_revision=hashlib.sha256(b'old').hexdigest(), + replace_strategy='recoverable_swap', + ) + + assert session._closed is True + assert all(remote_file.closed for remote_file in opened) + assert session.files == {session.destination: b'new'} + + +def test_recoverable_editor_closes_remaining_handle_after_close_interrupt( + monkeypatch, +): + backend, source, session = _stateful_fixture() + original_invoke = session.invoke + original_close = session._Handle.close + opened = [] + close_count = 0 + + def capture_handles(name, *args, **kwargs): + result = original_invoke(name, *args, **kwargs) + if name == 'open_file_move_verified': + opened.append(result[0]) + elif name == 'create_file_move_verified': + opened.append(result) + return result + + def close_then_interrupt_first(remote_file): + nonlocal close_count + close_count += 1 + original_close(remote_file) + if close_count == 1: + raise KeyboardInterrupt + + session.invoke = capture_handles + monkeypatch.setattr( + session._Handle, + 'close', + close_then_interrupt_first, + ) + + with pytest.raises(KeyboardInterrupt): + backend.write_file_text( + source, + '/report.txt', + 'new', + encoding='utf-8', + newline='lf', + expected_revision=hashlib.sha256(b'old').hexdigest(), + replace_strategy='recoverable_swap', + ) + + assert len(opened) == 2 + assert close_count == 2 + assert all(remote_file.closed for remote_file in opened) + assert session.files == {session.destination: b'new'} + + +def test_atomic_replace_never_cleans_an_ambiguous_rename_handle(): + backend, source, session = _stateful_fixture() + original_invoke = session.invoke + + def commit_then_lose_all_responses(name, *args, **kwargs): + if name == 'open_handle_matches_path_verified': + raise SMBProtocolError('TIMEOUT') + result = original_invoke(name, *args, **kwargs) + if name == 'rename_open_handle_verified': + raise SMBProtocolError('TIMEOUT') + return result + + session.invoke = commit_then_lose_all_responses + + with pytest.raises(SMBProtocolError) as caught: + with backend.open_atomic_writer( + source, + '/report.txt', + replace=True, + cancel_event=None, + ) as remote_file: + remote_file.write(b'new') + + assert caught.value.public_code == 'TIMEOUT' + assert session.files == {session.destination: b'new'} + assert not any( + name == 'delete_open_handle_verified' + for name, _args, _kwargs in session.calls + ) + + +def test_editor_swap_reconciles_committed_recovery_rename_response_loss(): + backend, source, session = _stateful_fixture() + original_invoke = session.invoke + response_lost = False + + def commit_first_rename_then_timeout(name, *args, **kwargs): + nonlocal response_lost + result = original_invoke(name, *args, **kwargs) + if name == 'rename_open_handle_verified' and not response_lost: + response_lost = True + raise SMBProtocolError('TIMEOUT') + return result + + session.invoke = commit_first_rename_then_timeout + + outcome = backend.write_file_text( + source, + '/report.txt', + 'new', + encoding='utf-8', + newline='lf', + expected_revision=hashlib.sha256(b'old').hexdigest(), + replace_strategy='recoverable_swap', + ) + + assert outcome.success is True + assert session.files == {session.destination: b'new'} + + +def test_editor_swap_reports_artifacts_when_rename_outcome_is_ambiguous(): + backend, source, session = _stateful_fixture() + original_invoke = session.invoke + + def commit_then_lose_all_responses(name, *args, **kwargs): + if name == 'open_handle_matches_path_verified': + raise SMBProtocolError('TIMEOUT') + result = original_invoke(name, *args, **kwargs) + if name == 'rename_open_handle_verified': + raise SMBProtocolError('TIMEOUT') + return result + + session.invoke = commit_then_lose_all_responses + + outcome = backend.write_file_text( + source, + '/report.txt', + 'new', + encoding='utf-8', + newline='lf', + expected_revision=hashlib.sha256(b'old').hexdigest(), + replace_strategy='recoverable_swap', + ) + + assert outcome.success is False + assert outcome.code == 'SMB_RECOVERY_REQUIRED' + assert len(outcome.recovery_leaves) == 2 + assert session.destination not in session.files + assert any('.webssh-recovery-' in path for path in session.files) + assert any('.webssh-write-' in path for path in session.files) + + +def test_recoverable_editor_cleans_staging_file_on_base_exception( + monkeypatch, +): + backend, source, session = _stateful_fixture() + original_write_all = backend._write_all + + def interrupt_after_write(remote_file, data): + original_write_all(remote_file, data) + raise KeyboardInterrupt + + monkeypatch.setattr(backend, '_write_all', interrupt_after_write) + + with pytest.raises(KeyboardInterrupt): + backend.write_file_text( + source, + '/report.txt', + 'new', + encoding='utf-8', + newline='lf', + expected_revision=hashlib.sha256(b'old').hexdigest(), + replace_strategy='recoverable_swap', + ) + + assert session.files == {session.destination: b'old'} + + +@pytest.mark.parametrize( + ('target_rename', 'timing'), + ((1, 'before'), (1, 'after'), (2, 'before'), (2, 'after')), +) +def test_recoverable_editor_rolls_back_base_exception_around_each_rename( + target_rename, + timing, +): + backend, source, session = _stateful_fixture() + original_invoke = session.invoke + rename_count = 0 + interrupted = False + + def interrupt_rename(name, *args, **kwargs): + nonlocal interrupted, rename_count + if name != 'rename_open_handle_verified' or interrupted: + return original_invoke(name, *args, **kwargs) + rename_count += 1 + if rename_count == target_rename and timing == 'before': + interrupted = True + raise KeyboardInterrupt + result = original_invoke(name, *args, **kwargs) + if rename_count == target_rename and timing == 'after': + interrupted = True + raise KeyboardInterrupt + return result + + session.invoke = interrupt_rename + + with pytest.raises(KeyboardInterrupt): + backend.write_file_text( + source, + '/report.txt', + 'new', + encoding='utf-8', + newline='lf', + expected_revision=hashlib.sha256(b'old').hexdigest(), + replace_strategy='recoverable_swap', + ) + + assert interrupted is True + assert session.files == {session.destination: b'old'} + + +def test_recoverable_editor_preserves_install_if_backup_delete_is_interrupted(): + backend, source, session = _stateful_fixture() + original_invoke = session.invoke + interrupted = False + + def delete_then_interrupt(name, *args, **kwargs): + nonlocal interrupted + result = original_invoke(name, *args, **kwargs) + if name == 'delete_open_handle_verified' and not interrupted: + interrupted = True + raise KeyboardInterrupt + return result + + session.invoke = delete_then_interrupt + + with pytest.raises(KeyboardInterrupt): + backend.write_file_text( + source, + '/report.txt', + 'new', + encoding='utf-8', + newline='lf', + expected_revision=hashlib.sha256(b'old').hexdigest(), + replace_strategy='recoverable_swap', + ) + + assert interrupted is True + assert session.files == {session.destination: b'new'} + + def test_cancelled_atomic_write_cleans_only_generated_temp(): backend, source, session = _fixture() session.responses['open_file'] = _Writable() cancelled = Event() - with pytest.raises(SMBBackendError, match='cancelled'): + with pytest.raises(FileOperationCancelled, match='cancelled'): with backend.open_atomic_writer( source, '/report.txt', @@ -672,10 +1599,36 @@ def test_cancelled_atomic_write_cleans_only_generated_temp(): cancelled.set() assert all( - args[0] != r'\\10.0.0.8\Docs\report.txt' + name != 'rename_open_handle_verified' for name, args, _kwargs in session.calls - if name == 'remove' ) + assert any( + name == 'delete_open_handle_verified' + for name, _args, _kwargs in session.calls + ) + + +def test_atomic_writer_cleans_exact_temp_on_base_exception(): + backend, source, session = _fixture() + writer = _Writable() + session.responses['open_file'] = writer + + with pytest.raises(KeyboardInterrupt): + with backend.open_atomic_writer( + source, + '/report.txt', + replace=True, + cancel_event=None, + ) as remote: + remote.write(b'partial') + raise KeyboardInterrupt + + deleted = [ + args[0] + for name, args, _kwargs in session.calls + if name == 'delete_open_handle_verified' + ] + assert deleted == [writer] def test_share_root_mutations_are_rejected_before_remote_io(): @@ -813,8 +1766,8 @@ def list_during_transfer(): listing_thread.join(2) assert listing_result == [([], None)] - assert any(call[0] == 'open_file_no_follow' for call in transfer_session.calls) - assert any(call[0] == 'scandir_no_follow' for call in control_session.calls) + assert any(call[0] == 'open_file_verified' for call in transfer_session.calls) + assert any(call[0] == 'scandir_verified' for call in control_session.calls) def test_read_paths_open_the_validated_object_without_following_reparse_points(): @@ -829,10 +1782,7 @@ def test_read_paths_open_the_validated_object_without_following_reparse_points() assert isinstance(lease, FileReaderLease) assert lease.reader.read() == b'abc' - assert any( - name == 'open_file_no_follow' and kwargs['mode'] == 'rb' - for name, _args, kwargs in session.calls - ) + assert any(name == 'open_file_verified' for name, _args, _kwargs in session.calls) assert all( name != 'open_file' for name, _args, _kwargs in session.calls @@ -848,11 +1798,80 @@ def test_read_paths_open_the_validated_object_without_following_reparse_points() ) assert error is None assert result['content'] == 'abc' - assert any( - name == 'open_file_no_follow' and kwargs['mode'] == 'rb' - for name, _args, kwargs in session.calls + assert any(name == 'open_file_verified' for name, _args, _kwargs in session.calls) + + +def test_reader_uses_the_protocol_verified_leaf_handle(): + backend, source, session = _fixture() + opened = _Readable(b'bound') + + def open_file(_path, **_kwargs): + return opened + + session.responses['open_file_verified'] = open_file + + with backend.open_reader(source, '/safe/file.txt') as lease: + assert lease.reader.read() == b'bound' + + assert opened.closed is True + assert session.calls[0][0] == 'open_file_verified' + + +def test_reader_rejects_a_replaced_enumerated_identity_chain(): + backend, source, session = _fixture() + file_unc = r'\\10.0.0.8\Docs\root\file.txt' + session.responses['pinned_identities'] = { + r'\\10.0.0.8\Docs\root': 99, + file_unc: 30, + } + opened = [] + session.responses['open_file_verified'] = ( + lambda *_args, **_kwargs: opened.append(True) ) + with pytest.raises(FileSourceChanged) as error: + with backend.open_reader( + source, + '/root/file.txt', + _expected_identities=(10, 30), + ): + pass + + assert error.value.public_code == 'SOURCE_CHANGED' + assert opened == [] + + +@pytest.mark.parametrize('public_code', [ + 'CONFLICT', + 'IDENTITY_UNAVAILABLE', + 'NOT_FOUND', + 'REPARSE_POINT_REJECTED', +]) +def test_reader_maps_expected_leaf_open_races_to_source_changed(public_code): + backend, source, session = _fixture() + session.responses['open_file_verified'] = SMBProtocolError(public_code) + + with pytest.raises(FileSourceChanged) as error: + with backend.open_reader( + source, + '/file.txt', + _expected_identities=(1,), + ): + pass + + assert error.value.public_code == 'SOURCE_CHANGED' + + +def test_reader_preserves_conflict_without_an_expected_identity_chain(): + backend, source, session = _fixture() + session.responses['open_file_verified'] = SMBProtocolError('CONFLICT') + + with pytest.raises(SMBProtocolError) as error: + with backend.open_reader(source, '/file.txt'): + pass + + assert error.value.public_code == 'CONFLICT' + def test_open_reader_uses_size_and_attributes_from_the_open_handle(): """The SMB2 CREATE response, not a pathname stat, binds read policy.""" @@ -894,20 +1913,13 @@ def consume(self): def test_iter_tree_is_bounded_and_never_enters_reparse_directories(): backend, source, session = _fixture() + nested = _Iterator([_Entry('file.txt', size=7)]) root = _Iterator([ _Entry('folder', directory=True), _Entry('link', directory=True, reparse=True), - ]) - nested = _Iterator([_Entry('file.txt', size=7)]) + ], children={'folder': nested}) - def scandir(path, **_kwargs): - if path == r'\\10.0.0.8\Docs\root': - return root - if path == r'\\10.0.0.8\Docs\root\folder': - return nested - raise AssertionError(f'unexpected traversal: {path}') - - session.responses['scandir'] = scandir + session.responses['scandir'] = root budget = _MemberBudget(3) entries = list(backend.iter_tree( @@ -924,6 +1936,11 @@ def scandir(path, **_kwargs): assert root.closed is True assert nested.closed is True assert budget.used == 3 + assert [ + call[0] for call in session.calls + if call[0] == 'scandir_verified' + ] == ['scandir_verified'] + assert root.child_open_calls == ['folder'] def test_iter_tree_checks_member_limit_before_entering_next_directory(): @@ -941,24 +1958,190 @@ def test_iter_tree_checks_member_limit_before_entering_next_directory(): )) assert len([ - call for call in session.calls if call[0] == 'scandir_no_follow' + call for call in session.calls if call[0] == 'scandir_verified' ]) == 1 -def test_recursive_delete_is_postorder_and_rejects_reparse_points(): +def test_iter_tree_rejects_replaced_child_before_opening_it(): backend, source, session = _fixture() - session.responses['stat'] = _Stat(mode=0o040755, attributes=0x10) - session.responses['scandir'] = _Iterator([ - _Entry('file.txt', size=3), - _Entry('sub', directory=True), + root_unc = r'\\10.0.0.8\Docs\root' + child_unc = root_unc + r'\child' + session.responses['pinned_identities'] = { + root_unc: 10, + child_unc: 99, + } + session.responses['scandir'] = _Iterator( + [_Entry('child', directory=True, identity=20)], + child_identities={'child': 99}, + ) + + with pytest.raises(FileSourceChanged) as error: + list(backend.iter_tree( + source, + '/root', + budget=_MemberBudget(2), + cancel_event=Event(), + )) + + assert error.value.public_code == 'SOURCE_CHANGED' + assert [ + args[0] for name, args, _kwargs in session.calls + if name == 'scandir_verified' + ] == [root_unc] + + +@pytest.mark.parametrize('public_code', [ + 'CONFLICT', + 'IDENTITY_UNAVAILABLE', + 'NOT_FOUND', + 'REPARSE_POINT_REJECTED', +]) +def test_iter_tree_maps_expected_root_races_to_source_changed(public_code): + backend, source, session = _fixture() + session.responses['scandir_verified'] = SMBProtocolError(public_code) + + with pytest.raises(FileSourceChanged) as error: + list(backend.iter_tree( + source, + '/root', + budget=_MemberBudget(1), + cancel_event=Event(), + _expected_identities=(1,), + )) + + assert error.value.public_code == 'SOURCE_CHANGED' + + +def test_iter_tree_preserves_unbound_root_not_found(): + backend, source, session = _fixture() + session.responses['scandir_verified'] = SMBProtocolError('NOT_FOUND') + + with pytest.raises(SMBProtocolError) as error: + list(backend.iter_tree( + source, + '/missing', + budget=_MemberBudget(1), + cancel_event=Event(), + )) + + assert error.value.public_code == 'NOT_FOUND' + + +@pytest.mark.parametrize('public_code', [ + 'CONFLICT', + 'IDENTITY_UNAVAILABLE', + 'NOT_FOUND', + 'REPARSE_POINT_REJECTED', +]) +def test_iter_tree_maps_enumerated_child_races_without_root_token( + public_code, +): + backend, source, session = _fixture() + + class RacingIterator(_Iterator): + def open_child_directory(self, entry): + self.child_open_calls.append(entry.name) + raise SMBProtocolError(public_code) + + root = RacingIterator([_Entry('child', directory=True, identity=20)]) + session.responses['scandir'] = root + + with pytest.raises(FileSourceChanged) as error: + list(backend.iter_tree( + source, + '/root', + budget=_MemberBudget(2), + cancel_event=Event(), + )) + + assert error.value.public_code == 'SOURCE_CHANGED' + assert root.child_open_calls == ['child'] + + +def test_iter_tree_holds_verified_child_scanner_until_it_is_exhausted(): + backend, source, session = _fixture() + root_unc = r'\\10.0.0.8\Docs\root' + child_unc = root_unc + r'\child' + replacement_attempts = [] + session.responses['pinned_identities'] = { + root_unc: 10, + child_unc: 20, + } + + class RacingChildIterator(_Iterator): + def __next__(self): + if getattr(self, '_primed', False): + replacement_attempts.append(not self.closed) + self._primed = True + return super().__next__() + + child = RacingChildIterator([ + _Entry('one.txt', identity=31), + _Entry('two.txt', identity=32), ]) + session.responses['scandir'] = _Iterator( + [_Entry('child', directory=True, identity=20)], + children={'child': child}, + ) + + entries = list(backend.iter_tree( + source, + '/root', + budget=_MemberBudget(3), + cancel_event=Event(), + )) + + assert [entry['path'] for entry in entries] == [ + '/root/child', + '/root/child/one.txt', + '/root/child/two.txt', + ] + assert replacement_attempts == [True, True] + assert child.closed is True + + +@pytest.mark.parametrize('missing_identity', [False, True]) +def test_iter_tree_fails_closed_when_entry_identity_is_unavailable( + missing_identity, +): + backend, source, session = _fixture() + entry = _Entry('unknown.txt', identity=0) + if missing_identity: + del entry.smb_info.file_id + iterator = _Iterator([entry]) + session.responses['scandir'] = iterator + + with pytest.raises(SMBBackendError, match='identity is unavailable'): + list(backend.iter_tree( + source, + '/', + budget=_MemberBudget(1), + cancel_event=Event(), + )) + + assert iterator.closed is True - def scandir(path, **_kwargs): - if path.endswith(r'\sub'): - return _Iterator([]) - return _Iterator([_Entry('file.txt', size=3), _Entry('sub', directory=True)]) - session.responses['scandir'] = scandir +def test_recursive_delete_is_postorder_and_rejects_reparse_points(): + backend, source, session = _fixture() + root_unc = r'\\10.0.0.8\Docs\folder' + session.responses['stat'] = _Stat( + mode=0o040755, + attributes=0x10, + identity=90, + ) + session.responses['pinned_identities'] = { + root_unc: 90, + root_unc + r'\file.txt': 91, + root_unc + r'\sub': 92, + } + session.responses['scandir'] = _Iterator( + [ + _Entry('file.txt', size=3, identity=91), + _Entry('sub', directory=True, identity=92), + ], + children={'sub': _Iterator([])}, + ) success, error = backend.delete( source, '/folder', @@ -970,14 +2153,107 @@ def scandir(path, **_kwargs): assert error is None assert success is True mutations = [ - (name, args[0]) for name, args, _kwargs in session.calls - if name in {'remove', 'rmdir'} + args[0] + for name, args, kwargs in session.calls + if name == 'delete_verified' ] assert mutations == [ - ('remove', r'\\10.0.0.8\Docs\folder\file.txt'), - ('rmdir', r'\\10.0.0.8\Docs\folder\sub'), - ('rmdir', r'\\10.0.0.8\Docs\folder'), + r'\\10.0.0.8\Docs\folder\file.txt', + r'\\10.0.0.8\Docs\folder\sub', + r'\\10.0.0.8\Docs\folder', ] + assert [ + kwargs['expected_identities'] + for name, _args, kwargs in session.calls + if name == 'delete_verified' + ] == [(90, 91), (90, 92), (90,)] + + +def test_recursive_delete_rejects_selected_root_replacement_before_scan(): + backend, source, session = _fixture() + root_unc = r'\\10.0.0.8\Docs\folder' + session.responses['stat'] = _Stat( + mode=0o040755, + attributes=0x10, + identity=40, + ) + session.responses['pinned_identities'] = {root_unc: 41} + session.responses['scandir'] = lambda *_args, **_kwargs: ( + (_ for _ in ()).throw( + AssertionError('replacement root was enumerated') + ) + ) + + success, error = backend.delete( + source, + '/folder', + recursive=True, + budget=_MemberBudget(1), + cancel_event=Event(), + ) + + assert success is False + assert error == 'File conflict' + assert not any( + name == 'delete_verified' + for name, _args, _kwargs in session.calls + ) + + +def test_recursive_delete_rejects_nested_parent_swap_after_traversal(): + backend, source, session = _fixture() + root_unc = r'\\10.0.0.8\Docs\root' + child_unc = root_unc + r'\child' + session.responses['stat'] = _Stat( + mode=0o040755, + attributes=0x10, + identity=10, + ) + session.responses['pinned_identities'] = { + root_unc: 10, + child_unc: 20, + } + deleted = [] + session.responses['delete_verified'] = ( + lambda path, **_kwargs: deleted.append(path) + ) + + def swapped_tree(*_args, **_kwargs): + yield { + 'name': 'child', + 'path': '/root/child', + 'size': 0, + 'mode': 0o040755, + 'is_dir': True, + 'is_symlink': False, + '_smb_identity': 20, + '_smb_identity_chain': (10, 20), + } + yield { + 'name': 'protected.txt', + 'path': '/root/child/protected.txt', + 'size': 1, + 'mode': 0o100644, + 'is_dir': False, + 'is_symlink': False, + '_smb_identity': 30, + '_smb_identity_chain': (10, 20, 30), + } + session.responses['pinned_identities'][child_unc] = 99 + + backend.iter_tree = swapped_tree + + success, error = backend.delete( + source, + '/root', + recursive=True, + budget=_MemberBudget(2), + cancel_event=Event(), + ) + + assert success is False + assert error == 'File conflict' + assert deleted == [] def test_binary_preview_is_bounded_even_if_file_grows(): diff --git a/tests/test_smb_paths.py b/tests/test_smb_paths.py index a3d084b0..85bad730 100644 --- a/tests/test_smb_paths.py +++ b/tests/test_smb_paths.py @@ -1,6 +1,11 @@ import pytest -from app.smb_paths import SMBPath, SMBPathRejected, SMBShareName +from app.smb_paths import ( + SMBPath, + SMBPathRejected, + SMBShareName, + SMB_PATH_MAX_COMPONENTS, +) @pytest.mark.parametrize( @@ -40,6 +45,15 @@ def test_root_unicode_and_case_are_preserved(): assert str(SMBPath.parse('/Case/File')) != str(SMBPath.parse('/case/file')) +def test_dollar_is_allowed_in_paths_but_not_share_names(): + path = SMBPath.parse('/reports/budget$.xlsx') + + assert str(path) == '/reports/budget$.xlsx' + assert str(path.parent().child('next$.xlsx')) == '/reports/next$.xlsx' + with pytest.raises(SMBPathRejected): + SMBShareName.parse('Finance$') + + def test_only_validated_ip_share_and_segments_build_unc(): path = SMBPath.parse('/Berichte/2026.txt') share = SMBShareName.parse('Dokumente') @@ -64,3 +78,16 @@ def test_length_limits_are_enforced(): SMBShareName.parse('a' * 81) with pytest.raises(SMBPathRejected): SMBPath.parse('/' + ('a' * 256)) + + +def test_component_depth_limit_applies_to_parse_and_child(): + maximum = '/' + '/'.join('a' for _ in range(SMB_PATH_MAX_COMPONENTS)) + path = SMBPath.parse(maximum) + + assert len(path.segments) == SMB_PATH_MAX_COMPONENTS + with pytest.raises(SMBPathRejected, match='too many components'): + SMBPath.parse(maximum + '/b') + with pytest.raises(SMBPathRejected, match='too many components'): + path.child('b') + with pytest.raises(SMBPathRejected, match='too many components'): + SMBPath(tuple('a' for _ in range(SMB_PATH_MAX_COMPONENTS + 1))) diff --git a/tests/test_smb_protocol_contract.py b/tests/test_smb_protocol_contract.py index 5765b595..3f37e939 100644 --- a/tests/test_smb_protocol_contract.py +++ b/tests/test_smb_protocol_contract.py @@ -122,6 +122,42 @@ def test_global_policy_is_credential_free_ntlm_and_dfs_disabled(): } +def test_real_global_policy_clears_and_blocks_process_wide_dfs(monkeypatch): + from app import smb_protocol + + config = smb_protocol.ClientConfig() + # ClientConfig is a dependency-owned process singleton. Register every + # mutated attribute with monkeypatch so this contract test cannot leak its + # seeded caches or method overrides into another test. + for attribute in ( + '_referral_cache', + '_domain_cache', + 'lookup_referral', + 'lookup_domain', + 'cache_referral', + ): + monkeypatch.setattr(config, attribute, getattr(config, attribute)) + config._referral_cache = [object()] + config._domain_cache = [object()] + + smb_protocol._RealSMBProtocol().configure_global( + username=None, + password=None, + domain_controller=None, + skip_dfs=True, + auth_protocol='ntlm', + require_secure_negotiate=True, + ) + + assert config._referral_cache == [] + assert config._domain_cache == [] + assert config.lookup_referral(['server', 'share']) is None + assert config.lookup_domain('server') is None + with pytest.raises(SMBProtocolError) as error: + config.cache_referral(object()) + assert error.value.public_code == 'SHARE_UNAVAILABLE' + + def test_connect_negotiates_exact_dialect_and_encryption_before_authentication(): protocol, session = _connect() @@ -237,6 +273,20 @@ def fail_connect(): assert protocol.connections[0].disconnected == 1 +@pytest.mark.parametrize('status', [ + NtStatus.STATUS_FILE_IS_A_DIRECTORY, + NtStatus.STATUS_NOT_A_DIRECTORY, +]) +def test_object_type_race_statuses_map_to_conflict(status): + from app import smb_protocol + + mapped = smb_protocol._mapped_protocol_error( + SMBOSError(status, 'redacted') + ) + + assert mapped.public_code == 'CONFLICT' + + def test_smbprotocol_logon_failure_maps_to_authentication_error(): protocol = _FakeProtocol() original_new_session = protocol.new_session @@ -256,6 +306,143 @@ def failing_session(*args, **kwargs): assert protocol.connections[0].disconnected == 1 +@pytest.mark.parametrize( + ('share_name', 'is_dfs_share'), + ( + (r'\\server\OtherShare', False), + (r'\\server\Docs', True), + (None, False), + (r'\\server\Docs', None), + ), +) +def test_raw_open_rejects_dfs_and_cross_share_tree_bindings( + monkeypatch, + share_name, + is_dfs_share, +): + from types import SimpleNamespace + + from app import smb_protocol + + opened = [] + + class Raw: + def __init__(self, *_args, **_kwargs): + self.fd = SimpleNamespace( + file_attributes=int( + smb_protocol.FileAttributes.FILE_ATTRIBUTE_DIRECTORY + ), + tree_connect=SimpleNamespace( + share_name=share_name, + is_dfs_share=is_dfs_share, + ), + ) + self.closed = False + self.opened = False + opened.append(self) + + def open(self): + self.opened = True + + def close(self): + self.closed = True + + monkeypatch.setattr(smb_protocol, 'SMBDirectoryIO', Raw) + + with pytest.raises(SMBProtocolError) as error: + smb_protocol._open_raw( + r'\\server\Docs\folder', + is_directory=True, + desired_access=1, + connection_kwargs={}, + ) + + assert error.value.public_code == 'SHARE_UNAVAILABLE' + assert opened[0].opened is False + assert opened[0].closed is True + + +def test_raw_open_accepts_a_direct_binding_to_the_requested_share(monkeypatch): + from types import SimpleNamespace + + from app import smb_protocol + + class Raw: + def __init__(self, *_args, **_kwargs): + self.fd = SimpleNamespace( + file_attributes=int( + smb_protocol.FileAttributes.FILE_ATTRIBUTE_DIRECTORY + ), + tree_connect=SimpleNamespace( + share_name=r'\\SERVER\DOCS\\', + is_dfs_share=False, + ), + ) + self.closed = False + + def open(self): + return None + + def close(self): + self.closed = True + + monkeypatch.setattr(smb_protocol, 'SMBDirectoryIO', Raw) + + raw = smb_protocol._open_raw( + r'\\server\Docs\folder', + is_directory=True, + desired_access=1, + connection_kwargs={}, + ) + + assert raw.closed is False + raw.close() + + +def test_raw_open_rechecks_share_binding_after_create(monkeypatch): + from types import SimpleNamespace + + from app import smb_protocol + + opened = [] + + class Raw: + def __init__(self, *_args, **_kwargs): + self.fd = SimpleNamespace( + file_attributes=int( + smb_protocol.FileAttributes.FILE_ATTRIBUTE_DIRECTORY + ), + tree_connect=SimpleNamespace( + share_name=r'\\server\Docs', + is_dfs_share=False, + ), + ) + self.closed = False + opened.append(self) + + def open(self): + self.fd.tree_connect = SimpleNamespace( + share_name=r'\\server\Redirected', + is_dfs_share=False, + ) + + def close(self): + self.closed = True + + monkeypatch.setattr(smb_protocol, 'SMBDirectoryIO', Raw) + + with pytest.raises(SMBProtocolError) as error: + smb_protocol._open_raw( + r'\\server\Docs\folder', + is_directory=True, + desired_access=1, + connection_kwargs={}, + ) + + assert error.value.public_code == 'SHARE_UNAVAILABLE' + assert opened[0].closed is True + + def test_each_source_uses_a_private_sealed_connection_cache(): _protocol, first = _connect() _protocol, second = _connect() @@ -267,6 +454,43 @@ def test_each_source_uses_a_private_sealed_connection_cache(): assert exc.value.public_code == 'SOURCE_UNAVAILABLE' +def test_sealed_connection_cache_returns_only_its_live_connection(): + _protocol, session = _connect() + key = '192.0.2.10:445' + + assert session.connection_cache.get(key) is session.raw_connection + + session.raw_connection.transport.connected = False + with pytest.raises(SMBProtocolError) as exc: + session.connection_cache.get(key) + + assert exc.value.public_code == 'SOURCE_UNAVAILABLE' + assert session.connection_cache[key] is session.raw_connection + + +def test_sealed_dead_cache_stops_dependency_before_reconnect(monkeypatch): + from smbclient import _pool + + _protocol, session = _connect() + session.raw_connection.transport.connected = False + constructions = [] + + def unexpected_connection(*args, **kwargs): + constructions.append((args, kwargs)) + raise AssertionError('a sealed source must never reconnect') + + monkeypatch.setattr(_pool, 'Connection', unexpected_connection) + + with pytest.raises(SMBProtocolError) as error: + _pool.register_session( + '192.0.2.10', + connection_cache=session.connection_cache, + ) + + assert error.value.public_code == 'SOURCE_UNAVAILABLE' + assert constructions == [] + + def test_dead_source_fails_closed_before_high_level_operation(): protocol, session = _connect() protocol.connections[0].transport.connected = False @@ -289,6 +513,49 @@ def test_close_is_idempotent_and_clears_private_cache(): assert protocol.connections[0].disconnected == 1 +@pytest.mark.parametrize('transport_only_disconnect_fails', [False, True]) +def test_real_close_forces_transport_shutdown_after_session_cleanup_failure( + transport_only_disconnect_fails, +): + from app import smb_protocol + + events = [] + + class Transport: + connected = True + + def close(self): + events.append(('transport-close',)) + self.connected = False + + class Connection: + def __init__(self): + self.transport = Transport() + + def disconnect(self, close=True, timeout=None): + events.append(('disconnect', close, timeout)) + if close: + raise RuntimeError('session cleanup failed') + if transport_only_disconnect_fails: + raise RuntimeError('transport-only cleanup failed') + self.transport.close() + + connection = Connection() + + with pytest.raises(RuntimeError, match='session cleanup failed'): + smb_protocol._RealSMBProtocol.close_connection( + connection, + timeout=19, + ) + + assert events[:2] == [ + ('disconnect', True, 19), + ('disconnect', False, 19), + ] + assert events[-1] == ('transport-close',) + assert connection.transport.connected is False + + def test_only_protocol_boundary_imports_smb_packages(): from pathlib import Path @@ -389,7 +656,7 @@ def close(self): def invoke(name, path, **kwargs): calls.append((name, path, kwargs)) - if name == 'scandir_no_follow': + if name == 'scandir_verified': return Iterator() return DirectoryHandle() @@ -403,7 +670,7 @@ def invoke(name, path, **kwargs): 'create_directory': 'granted', 'delete_children': 'granted', } - assert calls[0][0] == 'scandir_no_follow' + assert calls[0][0] == 'scandir_verified' assert [call[2]['desired_access'] for call in calls[1:]] == [ int(DirectoryAccessMask.FILE_ADD_FILE), int(DirectoryAccessMask.FILE_ADD_SUBDIRECTORY), @@ -438,7 +705,7 @@ def close(self): return None def invoke(name, _path, **_kwargs): - if name == 'scandir_no_follow': + if name == 'scandir_verified': return Iterator() raise next(responses) @@ -468,3 +735,1840 @@ def test_public_session_type_is_explicit(): _protocol, session = _connect() assert isinstance(session, SMBProtocolSession) + + +@pytest.mark.parametrize( + ('operation', 'args', 'kwargs'), + [ + ('remove', (r'\\10.0.0.8\Docs\file.txt',), {}), + ('delete_verified', (r'\\10.0.0.8\Docs\file.txt',), {}), + ('delete_open_handle_verified', (object(),), {}), + ('create_file_move_verified', (r'\\10.0.0.8\Docs\file.txt',), {}), + ('open_file_move_verified', (r'\\10.0.0.8\Docs\file.txt',), {}), + ( + 'rename', + ( + r'\\10.0.0.8\Docs\old.txt', + r'\\10.0.0.8\Docs\new.txt', + ), + {}, + ), + ( + 'rename_verified', + ( + r'\\10.0.0.8\Docs\old.txt', + r'\\10.0.0.8\Docs\new.txt', + ), + {}, + ), + ( + 'rename_open_handle_verified', + (object(), r'\\10.0.0.8\Docs\new.txt'), + {}, + ), + ( + 'open_file_no_follow', + (r'\\10.0.0.8\Docs\new.txt',), + {'mode': 'xb'}, + ), + ( + 'open_file_no_follow', + (r'\\10.0.0.8\Docs\existing.txt',), + {'mode': 'r+'}, + ), + ( + 'open_file_no_follow', + (r'\\10.0.0.8\Docs\existing.txt',), + {'mode': 'rb+'}, + ), + ( + 'open_file_no_follow', + (r'\\10.0.0.8\Docs\existing.txt',), + {'mode': 'r+b'}, + ), + ( + 'open_file', + (r'\\10.0.0.8\Docs\existing.txt',), + {'mode': 'wb'}, + ), + ], +) +def test_protocol_rejects_mutations_without_pinned_ancestor_handles( + operation, + args, + kwargs, +): + protocol, session = _connect() + + with pytest.raises(SMBProtocolError) as error: + session.invoke(operation, *args, **kwargs) + + assert error.value.public_code == 'MUTATION_GUARD_REQUIRED' + assert protocol.events[-1][0] == 'session-connect' + + +@pytest.mark.parametrize('mode', ['r', 'rb', 'rt']) +def test_protocol_allows_read_only_file_opens_without_mutation_guard(mode): + protocol, session = _connect() + protocol.invoke = lambda name, *args, **kwargs: (name, args, kwargs) + + name, _args, kwargs = session.invoke( + 'open_file_no_follow', + r'\\10.0.0.8\Docs\report.txt', + mode=mode, + ) + + assert name == 'open_file_no_follow' + assert kwargs['mode'] == mode + + +def test_verified_ancestor_handle_stays_open_through_mutation_guard(): + from app.smb_protocol import SMBObjectInfo + + protocol, session = _connect() + calls = [] + + class DirectoryHandle: + def __init__(self): + self.closed = False + + def close(self): + self.closed = True + + handle = DirectoryHandle() + info = SMBObjectInfo('safe', 72, 0x10, 0, 0, 1, (72,)) + + def invoke(name, *args, **kwargs): + calls.append((name, args, kwargs, handle.closed)) + if name == 'open_directory_verified': + return handle, info + return 'mutated' + + protocol.invoke = invoke + parent = r'\\10.0.0.8\Docs\safe' + leaf = parent + r'\file.txt' + + with session.pin_mutation_ancestors([parent]): + assert session.invoke('remove', leaf) == 'mutated' + assert handle.closed is False + + assert handle.closed is True + assert [call[0] for call in calls] == [ + 'open_directory_verified', + 'remove', + ] + + +def test_verified_guard_rejects_reparse_ancestor_before_mutation(): + protocol, session = _connect() + calls = [] + + def invoke(name, *_args, **_kwargs): + calls.append(name) + if name == 'open_directory_verified': + raise SMBProtocolError('REPARSE_POINT_REJECTED') + raise AssertionError('mutation reached the protocol') + + protocol.invoke = invoke + + with pytest.raises(SMBProtocolError) as error: + with session.pin_mutation_ancestors([ + r'\\10.0.0.8\Docs\unsafe' + ]): + session.invoke('remove', r'\\10.0.0.8\Docs\unsafe\file.txt') + + assert error.value.public_code == 'REPARSE_POINT_REJECTED' + assert calls == ['open_directory_verified'] + + +@pytest.mark.parametrize( + ('identity', 'expected_code'), + [(72, 'CONFLICT'), (0, 'IDENTITY_UNAVAILABLE')], +) +def test_verified_guard_rejects_bad_identity_and_closes_handle( + identity, + expected_code, +): + from app.smb_protocol import SMBObjectInfo + + protocol, session = _connect() + + class DirectoryHandle: + def __init__(self): + self.closed = False + + def close(self): + self.closed = True + + handle = DirectoryHandle() + info = SMBObjectInfo('safe', identity, 0x10, 0, 0, 1) + protocol.invoke = lambda *_args, **_kwargs: (handle, info) + parent = r'\\10.0.0.8\Docs\safe' + + with pytest.raises(SMBProtocolError) as error: + with session.pin_directories( + [parent], + expected_identities={parent: 71}, + ): + pass + + assert error.value.public_code == expected_code + assert handle.closed is True + + +def _object_info( + name, + identity, + *, + directory=False, + links=1, + chain=(), + attributes=None, +): + from app.smb_protocol import SMBObjectInfo + + return SMBObjectInfo( + name, + identity, + (0x10 if directory else 0) if attributes is None else attributes, + 0, + 0, + links, + chain, + ) + + +def test_verified_walk_binds_each_parent_entry_to_the_opened_child(monkeypatch): + from app import smb_protocol + + events = [] + + class Handle: + def __init__(self, path): + self.path = path + self.closed = False + + def close(self): + self.closed = True + events.append(('close', self.path)) + + handles = {} + + def open_raw(path, **_kwargs): + handle = handles[path] = Handle(path) + events.append(('open', path)) + return handle + + entries = { + (r'\\server\Docs', 'safe'): _object_info( + 'safe', 10, directory=True + ), + (r'\\server\Docs\safe', 'file.txt'): _object_info( + 'file.txt', 20 + ), + } + opened = { + r'\\server\Docs': _object_info('', 1, directory=True), + r'\\server\Docs\safe': _object_info('safe', 10, directory=True), + r'\\server\Docs\safe\file.txt': _object_info('file.txt', 20), + } + monkeypatch.setattr(smb_protocol, '_open_raw', open_raw) + monkeypatch.setattr( + smb_protocol, + '_query_exact_child', + lambda handle, name: entries[(handle.path, name)], + ) + monkeypatch.setattr( + smb_protocol, + '_query_open_info', + lambda handle, **_kwargs: opened[handle.path], + ) + + raw, info = smb_protocol._open_verified_path( + r'\\server\Docs\safe\file.txt', + purpose='file_read', + ) + + assert raw is handles[r'\\server\Docs\safe\file.txt'] + assert raw.closed is False + assert info.identity_chain == (10, 20) + assert handles[r'\\server\Docs'].closed is True + assert handles[r'\\server\Docs\safe'].closed is True + + +@pytest.mark.parametrize( + ('opened_ids', 'expected_opens'), + [ + ({'safe': 99, 'file.txt': 20}, 2), + ({'safe': 10, 'file.txt': 99}, 3), + ], +) +def test_verified_walk_rejects_intermediate_and_leaf_swaps( + monkeypatch, + opened_ids, + expected_opens, +): + from app import smb_protocol + + handles = [] + + class Handle: + def __init__(self, path): + self.path = path + self.closed = False + + def close(self): + self.closed = True + + def open_raw(path, **_kwargs): + handle = Handle(path) + handles.append(handle) + return handle + + def exact_child(handle, name): + return _object_info( + name, + 10 if name == 'safe' else 20, + directory=name == 'safe', + ) + + def open_info(handle, **_kwargs): + name = handle.path.rsplit('\\', 1)[-1] + return _object_info( + name, + opened_ids.get(name, 1), + directory=name in {'Docs', 'safe'}, + ) + + monkeypatch.setattr(smb_protocol, '_open_raw', open_raw) + monkeypatch.setattr(smb_protocol, '_query_exact_child', exact_child) + monkeypatch.setattr(smb_protocol, '_query_open_info', open_info) + + with pytest.raises(SMBProtocolError) as error: + smb_protocol._open_verified_path( + r'\\server\Docs\safe\file.txt', + purpose='file_read', + ) + + assert error.value.public_code == 'CONFLICT' + assert len(handles) == expected_opens + assert all(handle.closed for handle in handles) + + +@pytest.mark.parametrize('purpose', ['file_read', 'stat']) +def test_verified_walk_preserves_known_file_access_when_listing_is_denied( + monkeypatch, + purpose, +): + from app import smb_protocol + + root = r'\\server\Docs' + protected = root + r'\protected' + leaf = protected + r'\file.txt' + identities = {root: 1, protected: 10, leaf: 20} + successful = [] + attempts = [] + + list_access = int( + smb_protocol.DirectoryAccessMask.FILE_LIST_DIRECTORY + | smb_protocol.DirectoryAccessMask.FILE_READ_ATTRIBUTES + ) + read_attributes = int( + smb_protocol.FilePipePrinterAccessMask.FILE_READ_ATTRIBUTES + ) + + class Handle: + def __init__(self, path): + self.path = path + self.close_count = 0 + successful.append(self) + + def close(self): + self.close_count += 1 + + def open_raw(path, *, desired_access, **_kwargs): + attempts.append((path, desired_access)) + if path == protected and desired_access == list_access: + raise SMBProtocolError('PERMISSION_DENIED') + return Handle(path) + + def exact_child(handle, name): + assert handle.path == root + assert name == 'protected' + return _object_info(name, 10, directory=True) + + def open_info(handle, **_kwargs): + return _object_info( + handle.path.rsplit('\\', 1)[-1], + identities[handle.path], + directory=handle.path != leaf, + ) + + monkeypatch.setattr(smb_protocol, '_open_raw', open_raw) + monkeypatch.setattr(smb_protocol, '_open_untyped_raw', open_raw) + monkeypatch.setattr(smb_protocol, '_query_exact_child', exact_child) + monkeypatch.setattr(smb_protocol, '_query_open_info', open_info) + + raw, info = smb_protocol._open_verified_path( + leaf, + purpose=purpose, + ) + + assert raw.path == leaf + assert raw.close_count == 0 + assert info.identity_chain == (10, 20) + assert attempts.count((protected, list_access)) == 1 + assert attempts.count((root, read_attributes)) == 1 + # One metadata handle is retained and one independent verifier reopens it. + assert attempts.count((protected, read_attributes)) == 2 + initial_root = successful[0] + held_protected = next( + handle for handle in successful[1:] if handle.path == protected + ) + assert initial_root.close_count == 1 + assert held_protected.close_count == 1 + raw.close() + assert all(handle.close_count == 1 for handle in successful) + + +def test_verified_walk_preserves_access_when_exact_child_query_is_denied( + monkeypatch, +): + from app import smb_protocol + + root = r'\\server\Docs' + visible = root + r'\visible' + opaque = visible + r'\opaque' + leaf = opaque + r'\file.txt' + identities = {root: 1, visible: 10, opaque: 20, leaf: 30} + exact_queries = [] + handles = [] + + class Handle: + def __init__(self, path, desired_access, share_access): + self.path = path + self.desired_access = desired_access + self.share_access = share_access + self.closed = False + handles.append(self) + + def close(self): + self.closed = True + + def open_raw( + path, + *, + desired_access, + share_access='rwd', + **_kwargs, + ): + return Handle(path, desired_access, share_access) + + def exact_child(handle, name): + exact_queries.append((handle.path, name)) + if handle.path == root: + return _object_info('visible', 10, directory=True) + if handle.path == visible: + raise SMBProtocolError('PERMISSION_DENIED') + raise AssertionError('opaque directory was queried') + + def open_info(handle, **_kwargs): + return _object_info( + handle.path.rsplit('\\', 1)[-1], + identities[handle.path], + directory=handle.path != leaf, + ) + + monkeypatch.setattr(smb_protocol, '_open_raw', open_raw) + monkeypatch.setattr(smb_protocol, '_query_exact_child', exact_child) + monkeypatch.setattr(smb_protocol, '_query_open_info', open_info) + + raw, info = smb_protocol._open_verified_path( + leaf, + purpose='file_read', + ) + + assert info.identity_chain == (10, 20, 30) + assert exact_queries == [(root, 'visible'), (visible, 'opaque')] + visible_handles = [handle for handle in handles if handle.path == visible] + assert [handle.share_access for handle in visible_handles[:2]] == [ + 'rwd', + 'r', + ] + opaque_handles = [handle for handle in handles if handle.path == opaque] + assert opaque_handles[0].share_access == 'r' + raw.close() + assert all(handle.closed for handle in handles) + + +@pytest.mark.parametrize( + ('purpose', 'suffix', 'leaf_is_directory', 'expected_chain'), + [ + ('file_read', r'\protected\file.txt', False, (10, 20)), + ('stat', r'\protected\file.txt', False, (10, 20)), + ('directory', r'\protected', True, (10,)), + ('directory_pin', r'\protected', True, (10,)), + ], +) +def test_verified_walk_preserves_known_paths_when_share_listing_is_denied( + monkeypatch, + purpose, + suffix, + leaf_is_directory, + expected_chain, +): + from app import smb_protocol + + root = r'\\server\Docs' + target = root + suffix + identities = { + root: 1, + root + r'\protected': 10, + root + r'\protected\file.txt': 20, + } + list_access = int( + smb_protocol.DirectoryAccessMask.FILE_LIST_DIRECTORY + | smb_protocol.DirectoryAccessMask.FILE_READ_ATTRIBUTES + ) + read_attributes = int( + smb_protocol.FilePipePrinterAccessMask.FILE_READ_ATTRIBUTES + ) + attempts = [] + handles = [] + + class Handle: + def __init__(self, path, desired_access): + self.path = path + self.desired_access = desired_access + self.close_count = 0 + handles.append(self) + + def close(self): + self.close_count += 1 + + def open_raw(path, *, desired_access, **_kwargs): + attempts.append((path, desired_access)) + if path == root and desired_access == list_access: + raise SMBProtocolError('PERMISSION_DENIED') + return Handle(path, desired_access) + + def open_info(handle, **_kwargs): + return _object_info( + handle.path.rsplit('\\', 1)[-1], + identities[handle.path], + directory=( + handle.path != root + r'\protected\file.txt' + ), + ) + + monkeypatch.setattr(smb_protocol, '_open_raw', open_raw) + monkeypatch.setattr(smb_protocol, '_open_untyped_raw', open_raw) + monkeypatch.setattr( + smb_protocol, + '_query_exact_child', + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError('opaque root must not be enumerated') + ), + ) + monkeypatch.setattr(smb_protocol, '_query_open_info', open_info) + + raw, info = smb_protocol._open_verified_path(target, purpose=purpose) + + assert info.identity_chain == expected_chain + assert bool(info.file_attributes & 0x10) is leaf_is_directory + assert attempts[0] == (root, list_access) + assert { + handle.desired_access + for handle in handles + if handle.path == root + } == {read_attributes} + assert sum(handle.close_count == 0 for handle in handles) == 1 + raw.close() + assert all(handle.close_count == 1 for handle in handles) + + +@pytest.mark.parametrize( + ('replacement_identity', 'replacement_attributes', 'expected_code'), + [ + (99, 0x10, 'CONFLICT'), + (10, 0, 'CONFLICT'), + (10, 0x410, 'REPARSE_POINT_REJECTED'), + ], +) +@pytest.mark.parametrize('purpose', ['stat', 'file_read']) +def test_verified_walk_opaque_fallback_rejects_one_way_swap( + monkeypatch, + purpose, + replacement_identity, + replacement_attributes, + expected_code, +): + from app import smb_protocol + + root = r'\\server\Docs' + protected = root + r'\protected' + leaf = protected + r'\file.txt' + identities = {root: 1, protected: 10, leaf: 20} + open_counts = {} + handles = [] + list_access = int( + smb_protocol.DirectoryAccessMask.FILE_LIST_DIRECTORY + | smb_protocol.DirectoryAccessMask.FILE_READ_ATTRIBUTES + ) + + class Handle: + def __init__(self, path, identity, attributes): + self.path = path + self.identity = identity + self.attributes = attributes + self.closed = False + handles.append(self) + + def close(self): + self.closed = True + + def open_raw(path, *, desired_access, **_kwargs): + open_counts[path] = open_counts.get(path, 0) + 1 + if path == protected and desired_access == list_access: + raise SMBProtocolError('PERMISSION_DENIED') + identity = identities[path] + attributes = 0 if path == leaf else 0x10 + # The second open of the protected directory is the verification + # pass. A synchronized replacement must not be accepted. + if path == protected and open_counts[path] == 3: + identity = replacement_identity + attributes = replacement_attributes + return Handle(path, identity, attributes) + + def exact_child(handle, name): + assert handle.path == root + assert name == 'protected' + return _object_info(name, 10, directory=True) + + def open_info(handle, **_kwargs): + return _object_info( + handle.path.rsplit('\\', 1)[-1], + handle.identity, + attributes=handle.attributes, + ) + + monkeypatch.setattr(smb_protocol, '_open_raw', open_raw) + monkeypatch.setattr(smb_protocol, '_open_untyped_raw', open_raw) + monkeypatch.setattr(smb_protocol, '_query_exact_child', exact_child) + monkeypatch.setattr(smb_protocol, '_query_open_info', open_info) + + with pytest.raises(SMBProtocolError) as error: + smb_protocol._open_verified_path(leaf, purpose=purpose) + + assert error.value.public_code == expected_code + assert all(handle.closed for handle in handles) + + +def test_verified_walk_opaque_prefix_pin_blocks_synchronized_aba(monkeypatch): + from app import smb_protocol + + root = r'\\server\Docs' + protected = root + r'\protected' + leaf = protected + r'\file.txt' + list_access = int( + smb_protocol.DirectoryAccessMask.FILE_LIST_DIRECTORY + | smb_protocol.DirectoryAccessMask.FILE_READ_ATTRIBUTES + ) + state = ['original'] + list_denied = [False] + occurrences = {} + handles = [] + swap_attempts = [] + + class Handle: + def __init__(self, path, share_access, info): + self.path = path + self.share_access = share_access + self.info = info + self.closed = False + handles.append(self) + + def close(self): + self.closed = True + + def try_swap(target): + blocked = any( + handle.path == protected + and not handle.closed + and 'd' not in handle.share_access + for handle in handles + ) + swap_attempts.append((target, blocked)) + if not blocked: + state[0] = target + + def object_info(path): + if path == root: + return _object_info('', 1, directory=True) + if path == protected: + return _object_info( + 'protected', + 100 if state[0] == 'original' else 200, + directory=True, + ) + if path == leaf: + return _object_info( + 'file.txt', + 101 if state[0] == 'original' else 201, + ) + raise AssertionError(f'unexpected path: {path}') + + def open_raw( + path, + *, + desired_access, + share_access='rwd', + **_kwargs, + ): + if path == root and desired_access == list_access and not list_denied[0]: + list_denied[0] = True + raise SMBProtocolError('PERMISSION_DENIED') + + occurrence = occurrences.get(path, 0) + 1 + occurrences[path] = occurrence + if path == protected and occurrence == 2: + try_swap('original') + elif path == leaf and occurrence == 2: + try_swap('alternate') + handle = Handle(path, share_access, object_info(path)) + if path == protected and occurrence == 1: + try_swap('alternate') + return handle + + monkeypatch.setattr(smb_protocol, '_open_raw', open_raw) + monkeypatch.setattr( + smb_protocol, + '_query_open_info', + lambda handle, **_kwargs: handle.info, + ) + monkeypatch.setattr( + smb_protocol, + '_query_exact_child', + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError('opaque root must not be enumerated') + ), + ) + + raw, info = smb_protocol._open_verified_path( + leaf, + purpose='file_read', + ) + + assert info.file_id == 101 + assert info.identity_chain == (100, 101) + assert swap_attempts == [ + ('alternate', True), + ('original', True), + ('alternate', True), + ] + protected_pin = next( + handle + for handle in handles + if handle.path == protected and handle.share_access == 'r' + ) + assert protected_pin.closed is True + raw.close() + assert all(handle.closed for handle in handles) + + +def test_verified_walk_opaque_prefix_pin_blocks_reparse_aba(monkeypatch): + from app import smb_protocol + + root = r'\\server\Docs' + protected = root + r'\protected' + leaf = protected + r'\file.txt' + list_access = int( + smb_protocol.DirectoryAccessMask.FILE_LIST_DIRECTORY + | smb_protocol.DirectoryAccessMask.FILE_READ_ATTRIBUTES + ) + state = ['original'] + list_denied = [False] + occurrences = {} + handles = [] + mutation_attempts = [] + + class Handle: + def __init__(self, path, share_access, info): + self.path = path + self.share_access = share_access + self.info = info + self.closed = False + handles.append(self) + + def close(self): + self.closed = True + + def try_reparse(target): + blocked = any( + handle.path == protected + and not handle.closed + and 'w' not in handle.share_access + for handle in handles + ) + mutation_attempts.append((target, blocked)) + if not blocked: + state[0] = target + + def object_info(path): + if path == root: + return _object_info('', 1, directory=True) + if path == protected: + return _object_info( + 'protected', + 100 if state[0] == 'original' else 200, + directory=True, + ) + if path == leaf: + return _object_info( + 'file.txt', + 101 if state[0] == 'original' else 201, + ) + raise AssertionError(f'unexpected path: {path}') + + def open_raw( + path, + *, + desired_access, + share_access='rwd', + **_kwargs, + ): + if path == root and desired_access == list_access and not list_denied[0]: + list_denied[0] = True + raise SMBProtocolError('PERMISSION_DENIED') + + occurrence = occurrences.get(path, 0) + 1 + occurrences[path] = occurrence + if path == protected and occurrence == 2: + try_reparse('original') + elif path == leaf and occurrence == 2: + try_reparse('alternate') + handle = Handle(path, share_access, object_info(path)) + if path == protected and occurrence == 1: + try_reparse('alternate') + return handle + + monkeypatch.setattr(smb_protocol, '_open_raw', open_raw) + monkeypatch.setattr( + smb_protocol, + '_query_open_info', + lambda handle, **_kwargs: handle.info, + ) + monkeypatch.setattr( + smb_protocol, + '_query_exact_child', + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError('opaque root must not be enumerated') + ), + ) + + raw, info = smb_protocol._open_verified_path( + leaf, + purpose='file_read', + ) + + assert info.file_id == 101 + assert info.identity_chain == (100, 101) + assert mutation_attempts == [ + ('alternate', True), + ('original', True), + ('alternate', True), + ] + protected_pin = next( + handle + for handle in handles + if handle.path == protected and handle.share_access == 'r' + ) + assert protected_pin.closed is True + raw.close() + assert all(handle.closed for handle in handles) + + +def test_verified_walk_does_not_fallback_for_non_permission_errors( + monkeypatch, +): + from app import smb_protocol + + root = r'\\server\Docs' + protected = root + r'\protected' + handles = [] + attempts = [] + + class Handle: + path = root + + def __init__(self): + self.closed = False + handles.append(self) + + def close(self): + self.closed = True + + def open_raw(path, **_kwargs): + attempts.append(path) + if path == protected: + raise RuntimeError('transport failed') + return Handle() + + monkeypatch.setattr(smb_protocol, '_open_raw', open_raw) + monkeypatch.setattr( + smb_protocol, + '_query_open_info', + lambda *_args, **_kwargs: _object_info('', 1, directory=True), + ) + monkeypatch.setattr( + smb_protocol, + '_query_exact_child', + lambda *_args, **_kwargs: _object_info( + 'protected', 10, directory=True + ), + ) + + with pytest.raises(RuntimeError, match='transport failed'): + smb_protocol._open_verified_path( + protected + r'\file.txt', + purpose='file_read', + ) + + assert attempts == [root, protected] + assert all(handle.closed for handle in handles) + + +def test_directory_pin_requests_the_verified_leaf_purpose( + monkeypatch, +): + from app import smb_protocol + + captured = {} + + def open_verified(path, **kwargs): + captured['path'] = path + captured['kwargs'] = kwargs + return object(), _object_info('protected', 10, directory=True) + + monkeypatch.setattr(smb_protocol, '_open_verified_path', open_verified) + + smb_protocol._verified_directory_handle(r'\\server\Docs\protected') + + assert captured == { + 'path': r'\\server\Docs\protected', + 'kwargs': { + 'purpose': 'directory_pin', + 'expected_identities': None, + }, + } + + +def test_directory_pin_restricts_only_the_final_verified_handle( + monkeypatch, +): + from app import smb_protocol + + root = r'\\server\Docs' + protected = root + r'\protected' + calls = [] + + class Handle: + def __init__(self, path): + self.path = path + + def close(self): + return None + + def open_raw(path, **kwargs): + calls.append((path, kwargs)) + return Handle(path) + + monkeypatch.setattr(smb_protocol, '_open_raw', open_raw) + monkeypatch.setattr( + smb_protocol, + '_query_exact_child', + lambda *_args, **_kwargs: _object_info( + 'protected', 10, directory=True + ), + ) + monkeypatch.setattr( + smb_protocol, + '_query_open_info', + lambda handle, **_kwargs: _object_info( + handle.path.rsplit('\\', 1)[-1], + 1 if handle.path == root else 10, + directory=True, + ), + ) + + handle, _info = smb_protocol._open_verified_path( + protected, + purpose='directory_pin', + ) + + assert calls[0][0] == root + assert calls[0][1]['share_access'] == 'rwd' + assert calls[0][1]['desired_access'] == int( + smb_protocol.DirectoryAccessMask.FILE_LIST_DIRECTORY + | smb_protocol.DirectoryAccessMask.FILE_READ_ATTRIBUTES + ) + assert calls[1][0] == protected + assert calls[1][1]['share_access'] == 'r' + assert calls[1][1]['desired_access'] == int( + smb_protocol.DirectoryAccessMask.FILE_READ_ATTRIBUTES + ) + handle.close() + + +def test_share_root_directory_pin_uses_read_attributes_only(monkeypatch): + from app import smb_protocol + + root = r'\\server\Docs' + calls = [] + + class Handle: + def close(self): + return None + + def open_raw(path, **kwargs): + calls.append((path, kwargs)) + return Handle() + + monkeypatch.setattr(smb_protocol, '_open_raw', open_raw) + monkeypatch.setattr( + smb_protocol, + '_query_open_info', + lambda *_args, **_kwargs: _object_info('', 1, directory=True), + ) + + handle, _info = smb_protocol._open_verified_path( + root, + purpose='directory_pin', + ) + + assert calls == [(root, { + 'is_directory': True, + 'desired_access': int( + smb_protocol.FilePipePrinterAccessMask.FILE_READ_ATTRIBUTES + ), + 'connection_kwargs': {}, + 'share_access': 'r', + })] + handle.close() + + +def test_explicit_empty_expected_identity_chain_rejects_non_root_path( + monkeypatch, +): + from app import smb_protocol + + monkeypatch.setattr( + smb_protocol, + '_open_raw', + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError('path was opened before validating the chain') + ), + ) + + with pytest.raises(SMBProtocolError) as error: + smb_protocol._open_verified_path( + r'\\server\Docs\file.txt', + purpose='file_read', + expected_identities=(), + ) + + assert error.value.public_code == 'OPERATION_FAILED' + + +def test_exact_child_no_result_fails_closed(): + from app import smb_protocol + + class Directory: + def query_directory(self, pattern, info_class): + assert pattern == 'missing.txt' + assert info_class == ( + smb_protocol.FileInformationClass + .FILE_ID_FULL_DIRECTORY_INFORMATION + ) + return iter(()) + + with pytest.raises(SMBProtocolError) as error: + smb_protocol._query_exact_child(Directory(), 'missing.txt') + + assert error.value.public_code == 'NOT_FOUND' + + +def test_verified_iterator_closes_raw_when_enumeration_setup_fails(): + from app import smb_protocol + + class Raw: + closed = False + + def query_directory(self, *_args): + raise RuntimeError('query setup failed') + + def close(self): + self.closed = True + + raw = Raw() + with pytest.raises(RuntimeError, match='query setup failed'): + smb_protocol._VerifiedDirectoryIterator( + raw, + _object_info('', 1, directory=True), + ) + assert raw.closed is True + + +def test_verified_iterator_opens_enumerated_child_without_root_rewalk( + monkeypatch, +): + from app import smb_protocol + + trusted = _object_info('child', 20, directory=True) + opened = [] + + class Raw: + def __init__(self, entries): + self.entries = entries + self.closed = False + + def query_directory(self, *_args): + return iter(self.entries) + + def close(self): + self.closed = True + + parent_raw = Raw([object()]) + child_raw = Raw([]) + parent = smb_protocol._VerifiedDirectoryIterator( + parent_raw, + _object_info('', 10, directory=True, chain=(10,)), + path=r'\\server\Docs\parent', + connection_kwargs={'connection_timeout': 30}, + ) + monkeypatch.setattr( + smb_protocol, + '_entry_from_directory_info', + lambda _raw_info: trusted, + ) + + def open_raw(path, **kwargs): + opened.append((path, kwargs)) + return child_raw + + monkeypatch.setattr(smb_protocol, '_open_raw', open_raw) + monkeypatch.setattr( + smb_protocol, + '_query_open_info', + lambda *_args, **_kwargs: _object_info( + 'child', 20, directory=True + ), + ) + + entry = next(parent) + child = parent.open_child_directory(entry) + + assert child.identity == 20 + assert child.identity_chain == (10, 20) + assert opened[0][0] == r'\\server\Docs\parent\child' + assert opened[0][1]['connection_kwargs'] == { + 'connection_timeout': 30 + } + assert opened[0][1]['desired_access'] == int( + smb_protocol.DirectoryAccessMask.FILE_LIST_DIRECTORY + | smb_protocol.DirectoryAccessMask.FILE_READ_ATTRIBUTES + ) + assert parent_raw.closed is False + child.close() + parent.close() + assert child_raw.closed is True + assert parent_raw.closed is True + + +def test_verified_iterator_child_identity_mismatch_closes_child( + monkeypatch, +): + from app import smb_protocol + + class Raw: + def __init__(self): + self.closed = False + + def query_directory(self, *_args): + return iter(()) + + def close(self): + self.closed = True + + parent_raw = Raw() + child_raw = Raw() + parent = smb_protocol._VerifiedDirectoryIterator( + parent_raw, + _object_info('', 10, directory=True, chain=(10,)), + path=r'\\server\Docs\parent', + connection_kwargs={}, + ) + monkeypatch.setattr( + smb_protocol, + '_open_raw', + lambda *_args, **_kwargs: child_raw, + ) + monkeypatch.setattr( + smb_protocol, + '_query_open_info', + lambda *_args, **_kwargs: _object_info( + 'child', 99, directory=True + ), + ) + + with pytest.raises(SMBProtocolError) as error: + parent.open_child_directory( + _object_info('child', 20, directory=True) + ) + + assert error.value.public_code == 'CONFLICT' + assert child_raw.closed is True + assert parent_raw.closed is False + parent.close() + assert parent_raw.closed is True + + +def test_open_handle_rename_uses_relative_unicode_target(monkeypatch): + from types import SimpleNamespace + + from app import smb_protocol + + captured = {} + + class Raw: + closed = False + fd = SimpleNamespace( + tree_connect=SimpleNamespace( + share_name=r'\\server\Docs', + is_dfs_share=False, + ), + ) + + raw = Raw() + + class Transaction: + def __init__(self, candidate): + assert candidate is raw + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + monkeypatch.setattr(smb_protocol, 'SMBFileTransaction', Transaction) + monkeypatch.setattr( + smb_protocol, + 'set_info', + lambda transaction, info: captured.update({ + 'transaction': transaction, + 'info': info, + }), + ) + + smb_protocol._rename_open_handle( + raw, + r'\\server\Docs\Berichte\Überblick.txt', + replace=True, + ) + + info = captured['info'] + assert info['replace_if_exists'].get_value() is True + assert info['root_directory'].get_value() == 0 + assert info['file_name'].get_value() == r'Berichte\Überblick.txt' + + +def test_open_handle_rename_rejects_closed_or_cross_share_handle(monkeypatch): + from types import SimpleNamespace + + from app import smb_protocol + + transactions = [] + + class Raw: + def __init__(self, *, closed, share='Docs'): + self.closed = closed + self.fd = SimpleNamespace( + tree_connect=SimpleNamespace( + share_name=rf'\\server\{share}', + is_dfs_share=False, + ), + ) + + monkeypatch.setattr( + smb_protocol, + 'SMBFileTransaction', + lambda raw: transactions.append(raw), + ) + + with pytest.raises(SMBProtocolError) as closed_error: + smb_protocol._rename_open_handle( + Raw(closed=True), + r'\\server\Docs\new.txt', + ) + with pytest.raises(SMBProtocolError) as share_error: + smb_protocol._rename_open_handle( + Raw(closed=False), + r'\\server\Other\new.txt', + ) + + assert closed_error.value.public_code == 'OPERATION_FAILED' + assert share_error.value.public_code == 'SHARE_UNAVAILABLE' + assert transactions == [] + + +def test_open_handle_path_match_queries_the_existing_file_id(monkeypatch): + from types import SimpleNamespace + + from smbprotocol.file_info import FileAllInformation + + from app import smb_protocol + + class Raw: + closed = False + fd = SimpleNamespace( + tree_connect=SimpleNamespace( + share_name=r'\\server\Docs', + is_dfs_share=False, + ), + ) + + raw = Raw() + + class Transaction: + def __init__(self, candidate): + assert candidate is raw + self.results = [] + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def query(transaction, info_type, *, output_buffer_length=None): + assert info_type is FileAllInformation + assert output_buffer_length == 65536 + info = FileAllInformation() + info['name_information']['file_name'] = ( + r'\Berichte\Überblick.txt' + ) + transaction.results.append(info) + + monkeypatch.setattr(smb_protocol, 'SMBFileTransaction', Transaction) + monkeypatch.setattr(smb_protocol, 'query_info', query) + + assert smb_protocol._open_handle_matches_path( + raw, + r'\\server\Docs\berichte\überblick.txt', + ) is True + assert smb_protocol._open_handle_matches_path( + raw, + r'\\server\Docs\berichte\anderes.txt', + ) is False + + +def test_pinned_smbclient_builds_handle_name_query_with_sufficient_buffer(): + """Exercise smbclient 1.17's real query builder, not a protocol mock.""" + from types import SimpleNamespace + + from smbprotocol.file_info import FileAllInformation, InfoType + from smbprotocol.open import FileInformationClass + + from app import smb_protocol + + queued = [] + + class Transaction: + raw = SimpleNamespace(fd=SimpleNamespace(file_id=b'\0' * 16)) + + def __iadd__(self, operation): + queued.append(operation) + return self + + smb_protocol.query_info( + Transaction(), + FileAllInformation, + output_buffer_length=65536, + ) + + request, receiver = queued[0] + assert request['info_type'].get_value() == InfoType.SMB2_0_INFO_FILE + assert ( + request['file_info_class'].get_value() + == FileInformationClass.FILE_ALL_INFORMATION + ) + assert request['output_buffer_length'].get_value() == 65536 + assert callable(receiver) + + +def test_closed_handle_delete_and_query_never_reopen_by_path(monkeypatch): + from types import SimpleNamespace + + from app import smb_protocol + + raw = SimpleNamespace( + closed=True, + fd=SimpleNamespace( + tree_connect=SimpleNamespace( + share_name=r'\\server\Docs', + is_dfs_share=False, + ), + ), + ) + transactions = [] + monkeypatch.setattr( + smb_protocol, + 'SMBFileTransaction', + lambda candidate: transactions.append(candidate), + ) + + operations = ( + lambda: smb_protocol._set_delete_disposition(raw), + lambda: smb_protocol._open_handle_matches_path( + raw, + r'\\server\Docs\renamed.txt', + ), + ) + for operation in operations: + with pytest.raises(SMBProtocolError) as error: + operation() + assert error.value.public_code == 'OPERATION_FAILED' + assert transactions == [] + + +def test_verified_rename_mutates_and_closes_the_opened_source_handle( + monkeypatch, +): + from app import smb_protocol + + class Raw: + closed = False + + def close(self): + self.closed = True + + raw = Raw() + calls = [] + monkeypatch.setattr( + smb_protocol, + '_open_verified_path', + lambda path, **kwargs: ( + raw, + _object_info('old.txt', 83, chain=(83,)), + ), + ) + monkeypatch.setattr( + smb_protocol, + '_rename_open_handle', + lambda handle, destination, **kwargs: calls.append(( + handle, + destination, + kwargs, + )), + ) + + smb_protocol._verified_rename( + r'\\server\Docs\old.txt', + r'\\server\Docs\new.txt', + replace=False, + ) + + assert calls == [( + raw, + r'\\server\Docs\new.txt', + {'replace': False}, + )] + assert raw.closed is True + + +def test_editor_move_open_denies_concurrent_write_and_delete(monkeypatch): + from app import smb_protocol + + root = r'\\server\Docs' + leaf = root + r'\report.txt' + opens = [] + + class Raw: + def __init__(self, path): + self.path = path + + def close(self): + return None + + def open_raw(path, **kwargs): + opens.append((path, kwargs)) + return Raw(path) + + monkeypatch.setattr(smb_protocol, '_open_raw', open_raw) + monkeypatch.setattr( + smb_protocol, + '_query_exact_child', + lambda *_args: _object_info('report.txt', 83), + ) + monkeypatch.setattr( + smb_protocol, + '_query_open_info', + lambda raw, **_kwargs: _object_info( + raw.path.rsplit('\\', 1)[-1], + 1 if raw.path == root else 83, + directory=raw.path == root, + ), + ) + + raw, info = smb_protocol._verified_file_move_handle(leaf) + + leaf_open = next(call for call in opens if call[0] == leaf) + assert leaf_open[1]['share_access'] == 'r' + assert leaf_open[1]['desired_access'] == int( + smb_protocol.FilePipePrinterAccessMask.DELETE + | smb_protocol.FilePipePrinterAccessMask.FILE_READ_DATA + | smb_protocol.FilePipePrinterAccessMask.FILE_READ_ATTRIBUTES + ) + assert info.file_id == 83 + raw.close() + + +def test_atomic_temp_is_exclusively_created_with_same_handle_rename_rights( + monkeypatch, +): + from types import SimpleNamespace + + from app import smb_protocol + + captured = {} + + class Raw: + def __init__(self, path, **kwargs): + captured.update({'path': path, **kwargs}) + self.closed = True + self.fd = SimpleNamespace( + file_attributes=0, + tree_connect=SimpleNamespace( + share_name=r'\\server\Docs', + is_dfs_share=False, + ), + ) + + def open(self): + self.closed = False + + def close(self): + self.closed = True + + monkeypatch.setattr(smb_protocol, 'SMBFileIO', Raw) + + raw = smb_protocol._create_file_move_handle( + r'\\server\Docs\.report.webssh-write.tmp' + ) + + assert captured['mode'] == 'xb' + assert captured['share_access'] is None + assert captured['desired_access'] == int( + smb_protocol.FilePipePrinterAccessMask.DELETE + | smb_protocol.FilePipePrinterAccessMask.FILE_WRITE_DATA + | smb_protocol.FilePipePrinterAccessMask.FILE_READ_ATTRIBUTES + ) + assert captured['create_options'] & int( + smb_protocol.CreateOptions.FILE_OPEN_REPARSE_POINT + ) + assert raw.closed is False + raw.close() + + +def test_verified_delete_preserves_multiple_hardlink_compatibility(monkeypatch): + from smbprotocol.file_info import FileDispositionInformation + + from app import smb_protocol + + events = [] + + class Raw: + closed = False + + def close(self): + self.closed = True + events.append(('close', self)) + + raw = Raw() + + class Transaction: + def __init__(self, candidate): + assert candidate is raw + self.raw = candidate + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + monkeypatch.setattr( + smb_protocol, + '_open_verified_path', + lambda *_args, **_kwargs: ( + raw, + _object_info('file.txt', 83, links=2), + ), + ) + monkeypatch.setattr(smb_protocol, 'SMBFileTransaction', Transaction) + monkeypatch.setattr( + smb_protocol, + 'set_info', + lambda transaction, info: events.append(('set', transaction.raw, info)), + ) + + smb_protocol._verified_delete(r'\\server\Docs\file.txt') + + set_events = [event for event in events if event[0] == 'set'] + assert len(set_events) == 1 + assert all(event[1] is raw for event in set_events) + assert isinstance(set_events[-1][2], FileDispositionInformation) + assert set_events[-1][2]['delete_pending'].get_value() is True + assert events[-1] == ('close', raw) + + +def test_verified_delete_requests_no_write_attribute_right_on_fast_path( + monkeypatch, +): + from app import smb_protocol + + root = r'\\server\Docs' + leaf = root + r'\file.txt' + opened = [] + + class Raw: + def __init__(self, path): + self.path = path + + def close(self): + return None + + def open_raw(path, **kwargs): + opened.append((path, kwargs)) + return Raw(path) + + monkeypatch.setattr(smb_protocol, '_open_raw', open_raw) + monkeypatch.setattr( + smb_protocol, + '_query_exact_child', + lambda *_args: _object_info('file.txt', 83), + ) + monkeypatch.setattr( + smb_protocol, + '_query_open_info', + lambda raw, **_kwargs: _object_info( + raw.path.rsplit('\\', 1)[-1], + 1 if raw.path == root else 83, + directory=raw.path == root, + ), + ) + monkeypatch.setattr( + smb_protocol, + '_set_delete_disposition', + lambda _raw: None, + ) + + smb_protocol._verified_delete(leaf) + + leaf_open = next(call for call in opened if call[0] == leaf) + desired_access = leaf_open[1]['desired_access'] + assert desired_access & int( + smb_protocol.FilePipePrinterAccessMask.DELETE + ) + assert desired_access & int( + smb_protocol.FilePipePrinterAccessMask.FILE_READ_ATTRIBUTES + ) + assert not desired_access & int( + smb_protocol.FilePipePrinterAccessMask.FILE_WRITE_ATTRIBUTES + ) + + +@pytest.mark.parametrize( + ('status', 'attributes'), + [ + (NtStatus.STATUS_ACCESS_DENIED, 0x01), + (NtStatus.STATUS_CANNOT_DELETE, 0x00), + ], +) +def test_verified_delete_does_not_escalate_unrelated_failures( + monkeypatch, + status, + attributes, +): + from app import smb_protocol + + class StatusFailure(Exception): + def __init__(self): + self.ntstatus = status + + class Raw: + closed = False + + def close(self): + self.closed = True + + raw = Raw() + opens = [] + + def open_verified(*_args, **kwargs): + opens.append(kwargs) + return raw, _object_info( + 'file.txt', + 83, + attributes=attributes, + chain=(83,), + ) + + monkeypatch.setattr(smb_protocol, '_open_verified_path', open_verified) + monkeypatch.setattr( + smb_protocol, + '_set_delete_disposition', + lambda _raw: (_ for _ in ()).throw(StatusFailure()), + ) + + with pytest.raises(StatusFailure): + smb_protocol._verified_delete(r'\\server\Docs\file.txt') + + assert [call['purpose'] for call in opens] == ['delete'] + assert raw.closed is True + + +def test_verified_delete_read_only_fallback_rebinds_identity(monkeypatch): + from app import smb_protocol + + class CannotDelete(Exception): + ntstatus = NtStatus.STATUS_CANNOT_DELETE + + class Raw: + def __init__(self, name): + self.name = name + self.closed = False + + def close(self): + self.closed = True + + primary = Raw('primary') + fallback = Raw('fallback') + opens = [] + attributes = [] + + def open_verified(*_args, **kwargs): + opens.append(kwargs) + if len(opens) == 1: + return primary, _object_info( + 'file.txt', 83, attributes=0x03, chain=(83,) + ) + return fallback, _object_info( + 'file.txt', 83, attributes=0x03, chain=(83,) + ) + + def disposition(raw): + if raw is primary: + raise CannotDelete() + + monkeypatch.setattr(smb_protocol, '_open_verified_path', open_verified) + monkeypatch.setattr(smb_protocol, '_set_delete_disposition', disposition) + monkeypatch.setattr( + smb_protocol, + '_set_file_attributes', + lambda raw, value: attributes.append((raw.name, value)), + ) + + smb_protocol._verified_delete(r'\\server\Docs\file.txt') + + assert [call['purpose'] for call in opens] == [ + 'delete', + 'delete_read_only', + ] + assert opens[1]['expected_identities'] == (83,) + assert attributes == [('fallback', 0x02)] + assert primary.closed is True + assert fallback.closed is True + + +def test_verified_delete_restores_exact_attributes_when_fallback_fails( + monkeypatch, +): + from app import smb_protocol + + class CannotDelete(Exception): + ntstatus = NtStatus.STATUS_CANNOT_DELETE + + class Raw: + def __init__(self, name): + self.name = name + + def close(self): + return None + + primary = Raw('primary') + fallback = Raw('fallback') + attributes = [] + opens = 0 + + def open_verified(*_args, **_kwargs): + nonlocal opens + opens += 1 + raw = primary if opens == 1 else fallback + return raw, _object_info( + 'file.txt', 83, attributes=0x23, chain=(83,) + ) + + def disposition(raw): + if raw is primary: + raise CannotDelete() + raise RuntimeError('fallback delete failed') + + monkeypatch.setattr(smb_protocol, '_open_verified_path', open_verified) + monkeypatch.setattr(smb_protocol, '_set_delete_disposition', disposition) + monkeypatch.setattr( + smb_protocol, + '_set_file_attributes', + lambda raw, value: attributes.append((raw.name, value)), + ) + + with pytest.raises(RuntimeError, match='fallback delete failed'): + smb_protocol._verified_delete(r'\\server\Docs\file.txt') + + assert attributes == [('fallback', 0x22), ('fallback', 0x23)] + + +def test_verified_delete_handles_disappearing_read_only_flag(monkeypatch): + from app import smb_protocol + + class CannotDelete(Exception): + ntstatus = NtStatus.STATUS_CANNOT_DELETE + + class Raw: + def close(self): + return None + + opens = 0 + + def open_verified(*_args, **_kwargs): + nonlocal opens + opens += 1 + return Raw(), _object_info( + 'file.txt', + 83, + attributes=0x01 if opens == 1 else 0x00, + chain=(83,), + ) + + monkeypatch.setattr(smb_protocol, '_open_verified_path', open_verified) + monkeypatch.setattr( + smb_protocol, + '_set_delete_disposition', + lambda _raw: (_ for _ in ()).throw(CannotDelete()), + ) + + with pytest.raises(SMBProtocolError) as error: + smb_protocol._verified_delete(r'\\server\Docs\file.txt') + + assert error.value.public_code == 'CONFLICT' diff --git a/tests/test_smb_socket_events.py b/tests/test_smb_socket_events.py index e1be1641..584caaae 100644 --- a/tests/test_smb_socket_events.py +++ b/tests/test_smb_socket_events.py @@ -141,7 +141,12 @@ def request_close(self, *_args): @pytest.mark.parametrize( 'code', - ('PERMISSION_DENIED', 'SHARE_UNAVAILABLE', 'TIMEOUT'), + ( + 'PERMISSION_DENIED', + 'SHARE_UNAVAILABLE', + 'TIMEOUT', + 'IDENTITY_UNAVAILABLE', + ), ) def test_connect_preserves_actionable_share_failure_codes(app, monkeypatch, code): import app.socket_events as socket_events diff --git a/tests/test_socket_session_lifecycle.py b/tests/test_socket_session_lifecycle.py index 2a2c395e..718902e6 100644 --- a/tests/test_socket_session_lifecycle.py +++ b/tests/test_socket_session_lifecycle.py @@ -11,6 +11,11 @@ from app import socketio, ssh_manager from app.auth import register_user from app.file_sources import SourceHoldSet +from app.socket_protocol import SOCKET_WIRE_REVISION + + +def _socket_auth(): + return {'wire_revision': SOCKET_WIRE_REVISION} @pytest.fixture(scope='module') @@ -57,7 +62,10 @@ def _authenticated_socket(app, username='session_race_user'): assert response.status_code == 302 socket_client = socketio.test_client( - app, flask_test_client=http_client) + app, + flask_test_client=http_client, + auth=_socket_auth(), + ) assert socket_client.is_connected() socket_client.get_received() return socket_client, user_id @@ -84,6 +92,121 @@ def _collect_until(socket_client, event_name, timeout=5): return events +@pytest.mark.parametrize( + ('auth_payload', 'received_revision'), + ( + (None, None), + ({'wire_revision': SOCKET_WIRE_REVISION - 1}, SOCKET_WIRE_REVISION - 1), + ), +) +def test_socket_connect_rejects_incompatible_wire_revision_before_registration( + app, + monkeypatch, + auth_payload, + received_revision, +): + from app import socket_events + + username = f'wire_revision_{received_revision}' + with app.app_context(): + user, error = register_user(username, 'socket-password-123') + assert error is None + user_id = user.id + + http_client = _logged_in_http_client(app, username) + for owner, name in ( + (socket_events.socket_capacity, 'reserve'), + (socket_events.ssh_output_flow, 'register_socket'), + (socket_events, 'register_socket_session'), + (socket_events, 'restore_user_sessions'), + ): + monkeypatch.setattr( + owner, + name, + lambda *_args, _name=name, **_kwargs: pytest.fail( + f'incompatible socket reached {_name}' + ), + ) + + kwargs = {'flask_test_client': http_client} + if auth_payload is not None: + kwargs['auth'] = auth_payload + socket_client = socketio.test_client(app, **kwargs) + + assert not socket_client.is_connected() + assert socket_client.queue == [] + + from flask import request + from flask_socketio import ConnectionRefusedError + + with app.test_request_context('/socket.io'): + request.sid = 'wire-payload-contract' + with pytest.raises(ConnectionRefusedError) as rejected: + socket_events._reject_socket_protocol_mismatch( + auth_payload, + SimpleNamespace(username=username), + ) + assert rejected.value.error_args['message'] == ( + 'WebSSH was updated. Reload this page to continue.' + ) + mismatch = rejected.value.error_args['data'] + assert mismatch['status'] == 'reload_required' + assert mismatch['code'] == 'socket_protocol_mismatch' + assert mismatch['required_revision'] == SOCKET_WIRE_REVISION + assert 'Reload this page' in mismatch['message'] + if received_revision is None: + assert 'received_revision' not in mismatch + else: + assert mismatch['received_revision'] == received_revision + with app.app_context(): + from app.models import SocketSession + assert SocketSession.query.filter_by(user_id=user_id).count() == 0 + + +def test_socket_connect_accepts_current_wire_revision_and_restores_sessions( + app, + monkeypatch, +): + from app import socket_events + + username = 'wire_revision_current' + with app.app_context(): + user, error = register_user(username, 'socket-password-123') + assert error is None + user_id = user.id + + restored = [] + monkeypatch.setattr( + socket_events, + 'restore_user_sessions', + lambda owner_id, sid: restored.append((owner_id, sid)), + ) + socket_client = socketio.test_client( + app, + flask_test_client=_logged_in_http_client(app, username), + auth=_socket_auth(), + ) + + try: + assert socket_client.is_connected() + received = socket_client.get_received() + connected = next( + event['args'][0] + for event in received + if event['name'] == 'connected' + ) + assert connected == { + 'status': 'success', + 'username': username, + 'wire_revision': SOCKET_WIRE_REVISION, + } + assert len(restored) == 1 + assert restored[0][0] == user_id + finally: + if socket_client.is_connected(): + socket_client.disconnect() + + def test_socket_capacity_preserves_per_user_and_global_reserve(app, monkeypatch): import config @@ -98,6 +221,7 @@ def test_socket_capacity_preserves_per_user_and_global_reserve(app, monkeypatch) second_same_user = socketio.test_client( app, flask_test_client=_logged_in_http_client(app, 'capacity_first'), + auth=_socket_auth(), ) other_socket, _other_id = _authenticated_socket(app, 'capacity_other') @@ -108,6 +232,7 @@ def test_socket_capacity_preserves_per_user_and_global_reserve(app, monkeypatch) over_global_limit = socketio.test_client( app, flask_test_client=_logged_in_http_client(app, 'capacity_third'), + auth=_socket_auth(), ) try: @@ -150,6 +275,7 @@ def test_socket_connect_is_rejected_while_runtime_is_shutting_down( socket_client = socketio.test_client( app, flask_test_client=http_client, + auth=_socket_auth(), ) assert not socket_client.is_connected() @@ -157,6 +283,46 @@ def test_socket_connect_is_rejected_while_runtime_is_shutting_down( assert SocketSession.query.filter_by(user_id=user_id).count() == 0 +def test_socket_connect_rejects_restore_maintenance_before_authentication( + app, monkeypatch): + from app import maintenance_mode, socket_events + + username = 'maintenance_connect_user' + with app.app_context(): + user, error = register_user(username, 'socket-password-123') + assert error is None + user_id = user.id + + http_client = _logged_in_http_client(app, username) + monkeypatch.setattr(maintenance_mode, 'is_active', lambda: True) + for name in ('load_user', 'register_socket_session'): + monkeypatch.setattr( + socket_events, + name, + lambda *_args, _name=name, **_kwargs: pytest.fail( + f'maintenance socket reached {_name}' + ), + ) + monkeypatch.setattr( + socket_events, + 'restore_user_sessions', + lambda *_args, **_kwargs: pytest.fail( + 'maintenance socket restored persisted sessions' + ), + ) + + socket_client = socketio.test_client( + app, + flask_test_client=http_client, + auth=_socket_auth(), + ) + + assert not socket_client.is_connected() + with app.app_context(): + from app.models import SocketSession + assert SocketSession.query.filter_by(user_id=user_id).count() == 0 + + def test_locked_user_disconnect_still_cancels_owned_transfers(app, monkeypatch): from app import socket_events from app.models import User, db @@ -333,7 +499,11 @@ def test_last_socket_disconnect_cancels_user_transfers(app, monkeypatch): 'password': 'socket-password-123', }) assert response.status_code == 302 - second_socket = socketio.test_client(app, flask_test_client=second_http) + second_socket = socketio.test_client( + app, + flask_test_client=second_http, + auth=_socket_auth(), + ) assert second_socket.is_connected() second_socket.get_received() @@ -482,7 +652,11 @@ def test_disconnect_cancels_only_transfers_prepared_by_that_socket(app, monkeypa 'password': 'socket-password-123', }) assert response.status_code == 302 - second_socket = socketio.test_client(app, flask_test_client=second_http) + second_socket = socketio.test_client( + app, + flask_test_client=second_http, + auth=_socket_auth(), + ) assert second_socket.is_connected() second_socket.get_received() diff --git a/tests/test_ssh_manager.py b/tests/test_ssh_manager.py index f66d3ff2..c4d30436 100644 --- a/tests/test_ssh_manager.py +++ b/tests/test_ssh_manager.py @@ -1,5 +1,6 @@ import paramiko import pytest +import socket import threading from app import ssh_manager @@ -153,6 +154,7 @@ def test_output_snapshot_carries_a_monotone_sequence_watermark(): def install_ssh_clients(monkeypatch, *connect_errors): clients = ClientList() opened_sockets = [] + required_interfaces = [] def client_factory(): index = len(clients) @@ -174,13 +176,15 @@ def client_factory(): ), ) - def open_socket(target, timeout): + def open_socket(target, timeout, *, required_interface=None): result = FakeValidatedSocket(target.hostname) opened_sockets.append(result) + required_interfaces.append(required_interface) return result monkeypatch.setattr(ssh_manager, 'open_validated_socket', open_socket) clients.opened_sockets = opened_sockets + clients.required_interfaces = required_interfaces return clients @@ -212,6 +216,41 @@ def test_tailscale_backend_requires_exact_authorization_before_resolving( assert error == 'Tailscale SSH authorization is invalid' +def test_tailscale_backend_rejects_proxy_jump_before_reservation_or_network( + monkeypatch, +): + monkeypatch.setattr( + ssh_manager.quota_manager, + 'reserve', + lambda *_args, **_kwargs: pytest.fail( + 'Tailscale ProxyJump reserved a session' + ), + ) + monkeypatch.setattr( + ssh_manager, + 'open_validated_socket', + lambda *_args, **_kwargs: pytest.fail( + 'Tailscale ProxyJump opened a socket' + ), + ) + monkeypatch.setattr( + ssh_manager.paramiko, + 'SSHClient', + lambda: pytest.fail('Tailscale ProxyJump created an SSH client'), + ) + + session_id, error = connect_target( + auth_type='tailscale', + proxy_jump_host='bastion.example', + proxy_jump_port=22, + proxy_jump_username='jump-user', + proxy_jump_password='jump-password', + ) + + assert session_id is None + assert error == 'Tailscale SSH cannot be used with a jump host' + + def test_ssh_manager_exposes_the_shared_loader(): from app.ssh_key_loader import load_private_key @@ -495,7 +534,11 @@ def test_tailscale_tmux_forces_utf8_locale(monkeypatch): tailscale_authorization=TailscaleSSHAuthorization( user_id=7, host='target.example', + port=22, remote_username='alice', + resolved_target=ResolvedTarget( + 'target.example', 22, '192.0.2.10', socket.AF_INET + ), ), use_tmux=True, reconnect_tmux_name='existing_session', @@ -514,6 +557,7 @@ def test_tailscale_tmux_forces_utf8_locale(monkeypatch): 'auth_strategy': strategy, } assert ssh_manager.sessions[session_id]['auth_type'] == 'tailscale' + assert clients.required_interfaces == ['tailscale0'] probe_channel, tmux_channel = clients[0].transport.session_channels assert probe_channel.command == 'command -v tmux' assert tmux_channel.pty == ('xterm-256color', 80, 24) @@ -523,6 +567,16 @@ def test_tailscale_tmux_forces_utf8_locale(monkeypatch): ) +def test_password_connection_does_not_bind_a_network_interface(monkeypatch): + clients = install_ssh_clients(monkeypatch) + + session_id, error = connect_target(password='secret') + + assert error is None + assert session_id in ssh_manager.sessions + assert clients.required_interfaces == [None] + + def test_password_tmux_preserves_remote_locale(monkeypatch): clients = install_ssh_clients(monkeypatch) diff --git a/tests/test_storage_migrations.py b/tests/test_storage_migrations.py index 990f1266..3783c13c 100644 --- a/tests/test_storage_migrations.py +++ b/tests/test_storage_migrations.py @@ -89,6 +89,35 @@ def test_profiles_preserve_legacy_and_explicit_post_connect_semantics(): assert by_id['absent-override']['startup_mode'] == 'command' +@pytest.mark.parametrize('schema_version', [0, 1, 2]) +def test_profiles_migration_removes_response_only_tailscale_authorization( + schema_version, +): + source = { + 'schema_version': schema_version, + 'profiles': [ + { + 'id': 'tailnet-server', + 'name': 'Tailnet server', + 'tailscale_authorized': True, + }, + { + 'id': 'ordinary-server', + 'name': 'Ordinary server', + }, + ], + } + + migrated, changed = migrate_document('profiles', source) + + assert changed is True + assert migrated['schema_version'] == CURRENT_STORAGE_VERSIONS['profiles'] + assert 'tailscale_authorized' not in migrated['profiles'][0] + assert migrated['profiles'][1]['id'] == 'ordinary-server' + assert migrated['profiles'][1]['name'] == 'Ordinary server' + assert source['profiles'][0]['tailscale_authorized'] is True + + def test_future_versions_and_unknown_stores_are_rejected(): with pytest.raises(ValueError, match='future storage version'): migrate_document( @@ -354,6 +383,388 @@ def test_manager_rejects_future_version_without_backup_or_write(app): assert list(path.parent.glob('profiles.json.*.bak')) == [] +def test_profile_manager_rejects_response_only_field_in_current_document(app): + from app import profile_manager + from app.models import User, db + + with app.app_context(): + user_id = _create_user(app, 'migration-response-only-current') + path = db.session.get(User, user_id).get_data_dir() / 'profiles.json' + source = json.dumps({ + 'schema_version': CURRENT_STORAGE_VERSIONS['profiles'], + 'profiles': [ + { + 'id': 'tailnet-server', + 'name': 'Tailnet server', + 'tailscale_authorized': True, + } + ], + }, separators=(',', ':')).encode('utf-8') + path.write_bytes(source) + + with pytest.raises(StorageCorruptionError) as exc_info: + profile_manager.load_profiles(user_id) + + assert exc_info.value.reason == 'validation failed' + assert path.read_bytes() == source + assert list(path.parent.glob('profiles.json.*.bak')) == [] + + +def test_profile_manager_persists_response_only_field_migration(app): + from app import profile_manager + from app.models import User, db + + with app.app_context(): + user_id = _create_user(app, 'migration-response-only-legacy') + path = db.session.get(User, user_id).get_data_dir() / 'profiles.json' + source = json.dumps({ + 'schema_version': 2, + 'profiles': [ + { + 'id': 'tailnet-server', + 'name': 'Tailnet server', + 'tailscale_authorized': True, + } + ], + }, separators=(',', ':')).encode('utf-8') + path.write_bytes(source) + + loaded = profile_manager.load_profiles(user_id) + + assert loaded == [{ + 'id': 'tailnet-server', + 'name': 'Tailnet server', + }] + stored = json.loads(path.read_text(encoding='utf-8')) + assert stored == { + 'schema_version': CURRENT_STORAGE_VERSIONS['profiles'], + 'profiles': loaded, + } + backups = list(path.parent.glob('profiles.json.*.bak')) + assert len(backups) == 1 + assert backups[0].read_bytes() == source + + +def test_profile_v2_migration_uses_exact_compact_fallback_and_reloads( + app, + monkeypatch, +): + import config + from app import profile_manager + from app.models import User, db + + profiles = [ + {'id': str(index), 'name': 'x'} + for index in range(50) + ] + profiles[0]['tailscale_authorized'] = True + source_document = { + 'schema_version': 2, + 'profiles': profiles, + } + source = json.dumps( + source_document, + separators=(',', ':'), + ).encode('utf-8') + expected_profiles = [ + { + key: value + for key, value in profile.items() + if key != 'tailscale_authorized' + } + for profile in profiles + ] + expected = json.dumps({ + 'schema_version': CURRENT_STORAGE_VERSIONS['profiles'], + 'profiles': expected_profiles, + }, separators=(',', ':')).encode('utf-8') + assert len(source) == 1201 + assert len(expected) == 1173 + + with app.app_context(): + user_id = _create_user(app, 'migration-profile-exact-cap') + path = db.session.get(User, user_id).get_data_dir() / 'profiles.json' + path.write_bytes(source) + monkeypatch.setattr( + config, + 'CONNECTION_STORE_MAX_BYTES', + len(source), + ) + monkeypatch.setattr( + config, + 'CONNECTION_CONFIG_MAX_BYTES', + len(source), + ) + + first = profile_manager.load_profiles(user_id) + assert first == expected_profiles + assert path.read_bytes() == expected + backups = list(path.parent.glob('profiles.json.*.bak')) + assert len(backups) == 1 + assert backups[0].read_bytes() == source + + assert profile_manager.load_profiles(user_id) == expected_profiles + assert path.read_bytes() == expected + assert list(path.parent.glob('profiles.json.*.bak')) == backups + + +def test_jump_v1_migration_uses_exact_compact_fallback_and_reloads( + app, + monkeypatch, +): + import config + from app import jump_host_manager + from app.models import User, db + + jump_hosts = [ + { + 'id': str(index), + 'name': 'x', + 'host': 'b.example', + 'port': 22, + 'username': 'u', + 'auth_type': 'password', + } + for index in range(30) + ] + source = json.dumps({ + 'schema_version': 1, + 'jump_hosts': jump_hosts, + }, separators=(',', ':')).encode('utf-8') + expected = json.dumps({ + 'schema_version': CURRENT_STORAGE_VERSIONS['jump_hosts'], + 'jump_hosts': jump_hosts, + }, separators=(',', ':')).encode('utf-8') + assert len(source) == 2725 + assert len(expected) == len(source) + + with app.app_context(): + user_id = _create_user(app, 'migration-jump-exact-cap') + path = db.session.get(User, user_id).get_data_dir() / 'jump_hosts.json' + path.write_bytes(source) + monkeypatch.setattr( + config, + 'CONNECTION_STORE_MAX_BYTES', + len(source), + ) + monkeypatch.setattr( + config, + 'CONNECTION_CONFIG_MAX_BYTES', + len(source), + ) + + first = jump_host_manager.load_jump_hosts(user_id) + assert first == jump_hosts + assert path.read_bytes() == expected + backups = list(path.parent.glob('jump_hosts.json.*.bak')) + assert len(backups) == 1 + assert backups[0].read_bytes() == source + + assert jump_host_manager.load_jump_hosts(user_id) == jump_hosts + assert path.read_bytes() == expected + assert list(path.parent.glob('jump_hosts.json.*.bak')) == backups + + +def test_growing_profile_migration_stays_in_memory_without_touching_source( + app, + monkeypatch, +): + import config + from app import profile_manager + from app.models import User, db + + profiles = [ + {'id': str(index), 'name': 'x'} + for index in range(3) + ] + source = json.dumps({ + 'schema_version': 1, + 'profiles': profiles, + }, separators=(',', ':')).encode('utf-8') + expected = [ + {**profile, 'startup_mode': 'none'} + for profile in profiles + ] + assert len(source) == 99 + + with app.app_context(): + user_id = _create_user(app, 'migration-profile-in-memory') + path = db.session.get(User, user_id).get_data_dir() / 'profiles.json' + path.write_bytes(source) + monkeypatch.setattr( + config, + 'CONNECTION_STORE_MAX_BYTES', + len(source), + ) + monkeypatch.setattr( + config, + 'CONNECTION_CONFIG_MAX_BYTES', + len(source), + ) + + assert profile_manager.load_profiles(user_id) == expected + assert path.read_bytes() == source + assert list(path.parent.glob('profiles.json.*.bak')) == [] + + assert profile_manager.load_profiles(user_id) == expected + assert path.read_bytes() == source + assert list(path.parent.glob('profiles.json.*.bak')) == [] + + +def test_profile_and_jump_migrations_serialize_combined_quota_accounting( + app, + monkeypatch, +): + import config + from app import jump_host_manager, profile_manager, storage_migrations + from app.models import User, db + from app.storage_utils import storage_lock as real_storage_lock + + profile_source_document = { + 'profiles': [{'id': 'profile-1', 'name': 'Profile'}], + } + jump_source_document = { + 'jump_hosts': [{ + 'id': 'jump-1', + 'name': 'Jump', + 'host': 'jump.example', + 'port': 22, + 'username': 'deploy', + 'auth_type': 'password', + }], + } + profile_migrated, _ = migrate_document( + 'profiles', profile_source_document + ) + jump_migrated, _ = migrate_document( + 'jump_hosts', jump_source_document + ) + def encode(document): + return json.dumps( + document, + separators=(',', ':'), + ).encode('utf-8') + profile_source = encode(profile_source_document) + jump_source = encode(jump_source_document) + profile_payload = encode(profile_migrated) + jump_payload = encode(jump_migrated) + combined_cap = max( + len(profile_payload) + len(jump_source), + len(profile_source) + len(jump_payload), + ) + assert len(profile_payload) + len(jump_payload) > combined_cap + + first_write_entered = threading.Event() + release_first_write = threading.Event() + jump_coordinator_requested = threading.Event() + jump_payload_entered = threading.Event() + real_atomic_write_bytes = storage_migrations.atomic_write_bytes + real_jump_payload_factory = ( + jump_host_manager._jump_host_migration_payload + ) + results = {} + errors = [] + + def blocking_first_write(path, payload): + if not first_write_entered.is_set(): + first_write_entered.set() + if not release_first_write.wait(timeout=2): + raise AssertionError('timed out releasing first migration write') + return real_atomic_write_bytes(path, payload) + + def instrumented_jump_storage_lock(key): + if ( + threading.current_thread().name == 'jump-migration' + and key == f'command-config:{user_id}' + ): + jump_coordinator_requested.set() + return real_storage_lock(key) + + def observed_jump_payload_factory(path, document): + jump_payload_entered.set() + return real_jump_payload_factory(path, document) + + def load_profiles(): + try: + with app.app_context(): + results['profiles'] = profile_manager.load_profiles(user_id) + except BaseException as exc: # pragma: no cover - asserted below + errors.append(exc) + + def load_jump_hosts(): + try: + with app.app_context(): + results['jump_hosts'] = jump_host_manager.load_jump_hosts( + user_id + ) + except BaseException as exc: # pragma: no cover - asserted below + errors.append(exc) + + with app.app_context(): + user_id = _create_user(app, 'concurrent-migration-quota') + data_dir = db.session.get(User, user_id).get_data_dir() + profile_path = data_dir / 'profiles.json' + jump_path = data_dir / 'jump_hosts.json' + profile_path.write_bytes(profile_source) + jump_path.write_bytes(jump_source) + + monkeypatch.setattr(config, 'CONNECTION_STORE_MAX_BYTES', 10_000) + monkeypatch.setattr( + config, + 'CONNECTION_CONFIG_MAX_BYTES', + combined_cap, + ) + monkeypatch.setattr( + storage_migrations, + 'atomic_write_bytes', + blocking_first_write, + ) + monkeypatch.setattr( + jump_host_manager, + 'storage_lock', + instrumented_jump_storage_lock, + ) + monkeypatch.setattr( + jump_host_manager, + '_jump_host_migration_payload', + observed_jump_payload_factory, + ) + + profile_thread = threading.Thread( + target=load_profiles, + name='profile-migration', + daemon=True, + ) + jump_thread = threading.Thread( + target=load_jump_hosts, + name='jump-migration', + daemon=True, + ) + try: + profile_thread.start() + assert first_write_entered.wait(timeout=2) + jump_thread.start() + assert jump_coordinator_requested.wait(timeout=2) + assert jump_payload_entered.wait(timeout=0.1) is False + finally: + release_first_write.set() + profile_thread.join(timeout=2) + jump_thread.join(timeout=2) + + assert profile_thread.is_alive() is False + assert jump_thread.is_alive() is False + assert errors == [] + assert results == { + 'profiles': profile_migrated['profiles'], + 'jump_hosts': jump_migrated['jump_hosts'], + } + assert profile_path.read_bytes() == profile_payload + assert jump_path.read_bytes() == jump_source + assert profile_path.stat().st_size + jump_path.stat().st_size <= combined_cap + assert len(list(data_dir.glob('profiles.json.*.bak'))) == 1 + assert list(data_dir.glob('jump_hosts.json.*.bak')) == [] + + @pytest.mark.parametrize( ('store_name', 'relative_path', 'document', 'loader'), [ diff --git a/tests/test_storage_utils.py b/tests/test_storage_utils.py index f4d74a90..d4437710 100644 --- a/tests/test_storage_utils.py +++ b/tests/test_storage_utils.py @@ -630,3 +630,7 @@ def test_safe_reference_name_bounds_and_sanitizes_display_text(): assert safe_reference_name(None) == "" assert safe_reference_name("ok\x00name") == "ok\ufffdname" assert safe_reference_name("x" * 140) == "x" * 128 + multibyte = safe_reference_name("\n" + ("\u00e9" * 140)) + assert multibyte.startswith("\ufffd") + assert "\n" not in multibyte + assert len(multibyte.encode("utf-8")) <= 128 diff --git a/tests/test_tailscale_ssh.py b/tests/test_tailscale_ssh.py index fb2a189a..2edf905e 100644 --- a/tests/test_tailscale_ssh.py +++ b/tests/test_tailscale_ssh.py @@ -1,4 +1,7 @@ from types import SimpleNamespace +import ipaddress +import socket +import struct import threading import pytest @@ -8,13 +11,37 @@ pytestmark = pytest.mark.usefixtures('direct_socket_authentication') -def _set_policy(monkeypatch, *, enabled=True, users=(), targets=(), remote_users=()): +def _set_policy( + monkeypatch, + *, + enabled=True, + users=(), + targets=('tiny-server',), + remote_users=(), + interface='tailscale0', +): import config + from app import tailscale_ssh + from app.network_policy import ResolvedTarget monkeypatch.setattr(config, 'TAILSCALE_SSH_ENABLED', enabled) monkeypatch.setattr(config, 'TAILSCALE_SSH_ALLOWED_WEBSSH_USERS', frozenset(users)) monkeypatch.setattr(config, 'TAILSCALE_SSH_ALLOWED_TARGETS', frozenset(targets)) monkeypatch.setattr(config, 'TAILSCALE_SSH_ALLOWED_REMOTE_USERS', frozenset(remote_users)) + monkeypatch.setattr(config, 'TAILSCALE_SSH_INTERFACE', interface) + monkeypatch.setattr( + tailscale_ssh, + 'target_uses_tailscale_route', + lambda _address: True, + ) + + def resolve(host, port, allow_internal=False, *, target_validator=None): + target = ResolvedTarget(host, port, '100.64.0.10', 2) + if target_validator is not None and not target_validator(target): + raise ValueError('target rejected') + return target + + monkeypatch.setattr(tailscale_ssh, 'resolve_allowed_target', resolve) def test_tailscale_ssh_disabled_by_default(monkeypatch): @@ -28,6 +55,48 @@ def test_tailscale_ssh_disabled_by_default(monkeypatch): ) +@pytest.mark.parametrize( + ('raw_target', 'expected'), + ( + ('Tiny-Server.', ('tiny-server', 22)), + ('tiny-server:2200', ('tiny-server', 2200)), + ('100.64.0.10', ('100.64.0.10', 22)), + ('100.64.0.10:2200', ('100.64.0.10', 2200)), + ('fd7a:115c:a1e0::10', ('fd7a:115c:a1e0::10', 22)), + ('[fd7a:115c:a1e0::10]:2200', ('fd7a:115c:a1e0::10', 2200)), + ), +) +def test_tailscale_target_parser_canonicalizes_supported_forms( + raw_target, + expected, +): + import config + + assert config.parse_tailscale_ssh_target(raw_target) == expected + + +@pytest.mark.parametrize( + 'raw_target', + ( + '', + 'bad target', + '*', + 'tiny-server:0', + 'tiny-server:65536', + 'tiny-server:not-a-port', + '[fd7a:115c:a1e0::10', + '[fd7a:115c:a1e0::10]extra', + '[100.64.0.10]:22', + 'fe80::1%tailscale0', + ), +) +def test_tailscale_target_parser_rejects_malformed_entries(raw_target): + import config + + with pytest.raises(ValueError): + config.parse_tailscale_ssh_target(raw_target) + + def test_tailscale_ssh_allows_admin_when_enabled(monkeypatch): from app.tailscale_ssh import validate_tailscale_ssh_access @@ -72,6 +141,76 @@ def test_tailscale_ssh_enforces_target_and_remote_user_allowlists(monkeypatch): ) +def test_tailscale_ssh_requires_an_explicit_exact_host_and_port(monkeypatch): + from app.tailscale_ssh import validate_tailscale_ssh_access + + user = SimpleNamespace(username='admin', is_admin=True) + _set_policy(monkeypatch, targets=()) + assert validate_tailscale_ssh_access(user, 'tiny-server', 'root') == ( + 'Tailscale SSH target is not allowed' + ) + + _set_policy(monkeypatch, targets={'tiny-server:2200'}) + assert validate_tailscale_ssh_access( + user, 'tiny-server', 'root', port=2200 + ) is None + assert validate_tailscale_ssh_access( + user, 'tiny-server', 'root', port=22 + ) == 'Tailscale SSH target is not allowed' + + +def test_linux_route_lookup_uses_policy_routing_result(monkeypatch): + from app import tailscale_ssh + + destination = ipaddress.ip_address('100.64.1.2') + sent = [] + route_attributes = tailscale_ssh._netlink_attribute( + tailscale_ssh._RTA_OIF, + struct.pack('=I', 52), + ) + # A table-52 result models standard Tailscale policy routing; the output + # interface, not the main routing table, is the authorization boundary. + route_payload = tailscale_ssh._RTMSG.pack( + socket.AF_INET, 32, 0, 0, 52, 0, 0, 0, 0 + ) + route_attributes + response = tailscale_ssh._NLMSG_HEADER.pack( + tailscale_ssh._NLMSG_HEADER.size + len(route_payload), + tailscale_ssh._RTM_NEWROUTE, + 0, + 1, + 0, + ) + route_payload + + class FakeRouteSocket: + def settimeout(self, value): + assert value == 1.0 + + def bind(self, address): + assert address == (0, 0) + + def sendto(self, message, address): + sent.append(message) + assert address == (0, 0) + + def recv(self, _size): + return response + + def close(self): + pass + + monkeypatch.setattr( + tailscale_ssh.socket, + 'if_indextoname', + lambda index: 'tailscale0' if index == 52 else 'eth0', + ) + + assert tailscale_ssh._route_interface_for_ip( + destination.compressed, + socket_factory=lambda *_args: FakeRouteSocket(), + ) == 'tailscale0' + assert destination.packed in sent[0] + + def test_tailscale_ssh_fails_closed_for_invalid_configured_target(monkeypatch): from app.tailscale_ssh import validate_tailscale_ssh_access @@ -83,6 +222,42 @@ def test_tailscale_ssh_fails_closed_for_invalid_configured_target(monkeypatch): ) +def test_tailscale_ssh_ignores_malformed_target_beside_valid_sibling( + monkeypatch, +): + from app.tailscale_ssh import validate_tailscale_ssh_access + + _set_policy( + monkeypatch, + targets={'bad target', 'tiny-server:2200'}, + ) + user = SimpleNamespace(id=7, username='admin', is_admin=True) + + assert validate_tailscale_ssh_access( + user, + 'tiny-server', + 'root', + port=2200, + ) is None + assert validate_tailscale_ssh_access( + user, + 'tiny-server', + 'root', + port=22, + ) == 'Tailscale SSH target is not allowed' + + +def test_tailscale_ssh_fails_closed_when_interface_is_empty(monkeypatch): + from app.tailscale_ssh import validate_tailscale_ssh_access + + _set_policy(monkeypatch, interface='') + user = SimpleNamespace(id=7, username='admin', is_admin=True) + + assert validate_tailscale_ssh_access(user, 'tiny-server', 'root') == ( + 'Tailscale SSH target is not allowed' + ) + + def test_tailscale_authorization_is_bound_to_exact_user_target_and_remote_user( monkeypatch, ): @@ -98,10 +273,11 @@ def test_tailscale_authorization_is_bound_to_exact_user_target_and_remote_user( ) assert error is None - assert authorization.matches(7, 'tiny-server', 'root') - assert not authorization.matches(8, 'tiny-server', 'root') - assert not authorization.matches(7, 'other-server', 'root') - assert not authorization.matches(7, 'tiny-server', 'ubuntu') + assert authorization.matches(7, 'tiny-server', 22, 'root') + assert not authorization.matches(8, 'tiny-server', 22, 'root') + assert not authorization.matches(7, 'other-server', 22, 'root') + assert not authorization.matches(7, 'tiny-server', 2200, 'root') + assert not authorization.matches(7, 'tiny-server', 22, 'ubuntu') def test_profile_launch_authorization_tracks_target_policy(monkeypatch): @@ -149,18 +325,21 @@ def test_profile_list_includes_transient_tailscale_authorization( 'auth_type': 'tailscale', 'host': 'tiny-server', 'username': 'root', + 'tailscale_authorized': False, }, { 'id': 'denied', 'auth_type': 'tailscale', 'host': 'other-server', 'username': 'root', + 'tailscale_authorized': True, }, { 'id': 'key', 'auth_type': 'key', 'host': 'server.example', 'username': 'root', + 'tailscale_authorized': True, }, ] monkeypatch.setattr( @@ -181,7 +360,9 @@ def test_profile_list_includes_transient_tailscale_authorization( assert profiles[0]['tailscale_authorized'] is True assert profiles[1]['tailscale_authorized'] is False assert 'tailscale_authorized' not in profiles[2] - assert all('tailscale_authorized' not in profile for profile in stored_profiles) + assert stored_profiles[0]['tailscale_authorized'] is False + assert stored_profiles[1]['tailscale_authorized'] is True + assert stored_profiles[2]['tailscale_authorized'] is True def test_backend_rejects_unauthorized_tailscale_connection(app, monkeypatch): diff --git a/tests/test_threaded_runtime.py b/tests/test_threaded_runtime.py index 2a797d93..87a477f0 100644 --- a/tests/test_threaded_runtime.py +++ b/tests/test_threaded_runtime.py @@ -1,24 +1,20 @@ """Runtime contracts for the native-threading Socket.IO canary.""" +import importlib +import json import os -import socket import subprocess import sys import time import threading -import urllib.request +from datetime import datetime, timedelta, timezone from pathlib import Path import pytest PROJECT_ROOT = Path(__file__).resolve().parents[1] - - -def _free_loopback_port(): - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: - listener.bind(('127.0.0.1', 0)) - return listener.getsockname()[1] +ENGINEIO_BASE_URL = 'http://localhost:5000' def _config_probe(gunicorn_threads): @@ -43,6 +39,73 @@ def _config_probe(gunicorn_threads): ) +def _logged_in_http_client(app, username): + from app.auth import register_user + from flask import g + + with app.app_context(): + user, error = register_user(username, 'socket-password-123') + assert error is None + user_id = user.id + # conftest intentionally keeps one outer app context around the test; + # Flask-Login otherwise caches the previous request's user on that g. + g.pop('_login_user', None) + client = app.test_client() + response = client.post('/login', data={ + 'username': username, + 'password': 'socket-password-123', + }) + assert response.status_code == 302 + return client, user_id + + +def _create_bootstrap_user(app, username): + from app.auth import register_user + + with app.app_context(): + user, error = register_user(username, 'socket-password-123') + assert error is None + assert user.is_admin is True + + +def _mark_user_ldap_managed(app, user_id, username): + from app.models import LDAPIdentity, db + + with app.app_context(): + db.session.add(LDAPIdentity( + user_id=user_id, + provider='default', + subject=f'stable-{username}-id', + directory_username=username, + distinguished_name=f'uid={username},dc=example,dc=com', + )) + db.session.commit() + + +def _engineio_handshake(client, suffix): + from flask import g + + g.pop('_login_user', None) + return client.get( + f'/socket.io/?EIO=4&transport=polling&t={suffix}', + base_url=ENGINEIO_BASE_URL, + headers={'Origin': ENGINEIO_BASE_URL}, + ) + + +def _engineio_sid(response): + assert response.data.startswith(b'0{') + return json.loads(response.data[1:])['sid'] + + +def _close_engineio_socket(engineio_sid): + from app import socketio + + engineio_socket = socketio.server.eio.sockets[engineio_sid] + engineio_socket.close(wait=False, abort=True) + socketio.server.eio.sockets.pop(engineio_sid, None) + + def test_app_uses_native_threading_socketio_runtime(app): """An Eventlet fallback would change Socket.IO scheduling semantics.""" from app import socketio @@ -59,6 +122,1604 @@ def test_socketio_handlers_use_the_bounded_gthread_request_context(app): assert socketio.server.async_handlers is False +def test_engineio_rejects_unauthenticated_transport_before_retention(app): + from app import socketio + + capacity = app.extensions['engineio_socket_capacity'] + response = _engineio_handshake(app.test_client(), 'unauthenticated') + + assert response.status_code == 401 + assert response.headers['Access-Control-Allow-Origin'] == ENGINEIO_BASE_URL + assert response.data == b'"Unauthorized"' + assert socketio.server.eio.sockets == {} + assert capacity.count() == 0 + + +def test_engineio_admission_exception_fails_closed_without_retention( + app, + monkeypatch, +): + import app as app_package + from app import socketio + + capacity = app.extensions['engineio_socket_capacity'] + logged_errors = [] + + def fail_admission(_app, _environ): + raise RuntimeError('simulated admission failure') + + monkeypatch.setattr( + app_package, + '_engineio_admission_user', + fail_admission, + ) + monkeypatch.setattr( + app_package, + 'log_error', + lambda message, **details: logged_errors.append((message, details)), + ) + + response = _engineio_handshake(app.test_client(), 'admission-error') + + assert response.status_code == 401 + assert response.data == b'"Unauthorized"' + assert socketio.server.eio.sockets == {} + assert capacity.count() == 0 + assert len(logged_errors) == 1 + message, details = logged_errors[0] + assert message == 'Engine.IO transport admission failed closed' + assert details['error_type'] == 'RuntimeError' + assert isinstance(details['sid'], str) + assert details['sid'] + + +def test_engineio_rejects_browser_session_after_epoch_rotation(app): + from app import socketio + from app.session_epoch import rotate_epoch + + client, _user_id = _logged_in_http_client(app, 'engineio_epoch') + rotate_epoch() + + response = _engineio_handshake(client, 'rotated-epoch') + + assert response.status_code == 401 + assert response.data == b'"Unauthorized"' + assert socketio.server.eio.sockets == {} + assert app.extensions['engineio_socket_capacity'].count() == 0 + + +def test_engineio_final_admission_recheck_closes_revocation_race( + app, + monkeypatch, +): + import app as app_package + from app import socketio + from app.models import User, db + from app.user_lifecycle import revoke_user_access + + client, user_id = _logged_in_http_client( + app, + 'engineio_admission_revoke_race', + ) + original_admission = app_package._engineio_admission_user + validated = threading.Event() + resume = threading.Event() + + def pause_after_validation(flask_app, environ): + admitted_user = original_admission(flask_app, environ) + validated.set() + assert resume.wait(2) + return admitted_user + + monkeypatch.setattr( + app_package, + '_engineio_admission_user', + pause_after_validation, + ) + responses = [] + failures = [] + + def perform_handshake(): + try: + with app.app_context(): + responses.append(_engineio_handshake(client, 'revoke-race')) + except BaseException as error: + failures.append(error) + + worker = threading.Thread(target=perform_handshake) + worker.start() + assert validated.wait(1) + with app.app_context(): + user = db.session.get(User, user_id) + user.is_locked = True + db.session.commit() + result = revoke_user_access(user_id, socketio) + assert result['sockets'] == 0 + resume.set() + worker.join(3) + + assert not worker.is_alive() + assert failures == [] + assert len(responses) == 1 + assert responses[0].status_code == 401 + assert socketio.server.eio.sockets == {} + assert app.extensions['engineio_socket_capacity'].count() == 0 + + +@pytest.mark.parametrize('scheduler_failure', (False, True)) +def test_engineio_cleanup_waits_for_library_owned_handshake_frame( + app, + monkeypatch, + scheduler_failure, +): + import app as app_package + from app import socketio + from app.models import User, db + from app.user_lifecycle import revoke_user_access + + client, user_id = _logged_in_http_client( + app, + 'engineio_admission_cleanup_race', + ) + original_final_admission = app_package._engineio_admission_is_current + validated = threading.Event() + resume = threading.Event() + + def pause_after_final_validation(flask_app, environ, expected_user_id): + admitted = original_final_admission( + flask_app, + environ, + expected_user_id, + ) + assert admitted is True + validated.set() + assert resume.wait(2) + return admitted + + monkeypatch.setattr( + app_package, + '_engineio_admission_is_current', + pause_after_final_validation, + ) + responses = [] + failures = [] + + def perform_handshake(): + try: + with app.app_context(): + responses.append(_engineio_handshake(client, 'cleanup-race')) + except BaseException as error: + failures.append(error) + + worker = threading.Thread(target=perform_handshake) + worker.start() + assert validated.wait(1) + if scheduler_failure: + monkeypatch.setattr( + socketio.server.eio, + 'start_background_task', + lambda _target: (_ for _ in ()).throw( + RuntimeError('simulated scheduler failure') + ), + ) + with app.app_context(): + user = db.session.get(User, user_id) + user.is_locked = True + db.session.commit() + revoke_user_access(user_id, socketio) + + capacity = app.extensions['engineio_socket_capacity'] + assert capacity.count() == 1 + engineio_sid = capacity.sids_for_user(user_id)[0] + assert capacity.is_terminal(engineio_sid) is True + assert engineio_sid in socketio.server.eio.sockets + + resume.set() + worker.join(3) + + assert not worker.is_alive() + assert failures == [] + assert len(responses) == 1 + assert responses[0].status_code == 401 + assert socketio.server.eio.sockets == {} + assert capacity.count() == 0 + + +def test_engineio_final_admission_recheck_closes_restore_race( + app, + monkeypatch, +): + import app as app_package + from app import socketio + from app.restore_service import _disconnect_sockets + + client, _user_id = _logged_in_http_client( + app, + 'engineio_admission_restore_race', + ) + lifecycle = app.extensions['runtime_lifecycle'] + original_accepting_work = lifecycle.accepting_work + accepting_work = True + monkeypatch.setattr( + lifecycle, + 'accepting_work', + lambda: accepting_work and original_accepting_work(), + ) + original_admission = app_package._engineio_admission_user + validated = threading.Event() + resume = threading.Event() + + def pause_after_validation(flask_app, environ): + admitted_user = original_admission(flask_app, environ) + validated.set() + assert resume.wait(2) + return admitted_user + + monkeypatch.setattr( + app_package, + '_engineio_admission_user', + pause_after_validation, + ) + responses = [] + failures = [] + + def perform_handshake(): + try: + with app.app_context(): + responses.append(_engineio_handshake(client, 'restore-race')) + except BaseException as error: + failures.append(error) + + worker = threading.Thread(target=perform_handshake) + worker.start() + assert validated.wait(1) + accepting_work = False + _disconnect_sockets(socketio) + resume.set() + worker.join(3) + + assert not worker.is_alive() + assert failures == [] + assert len(responses) == 1 + assert responses[0].status_code == 401 + assert socketio.server.eio.sockets == {} + assert app.extensions['engineio_socket_capacity'].count() == 0 + + +def test_engineio_final_recheck_closes_background_ldap_revocation_race( + app, + monkeypatch, +): + import config + import app as app_package + import app.ldap_session as ldap_session + from app import socketio + from app.ldap_service import LDAPLookupRejected + from app.models import AuthenticationSession, LDAPIdentity, db + + client, user_id = _logged_in_http_client( + app, + 'engineio_admission_ldap_race', + ) + with app.app_context(): + db.session.add(LDAPIdentity( + user_id=user_id, + provider='default', + subject='stable-engineio-admission-id', + directory_username='engineio_admission_ldap_race', + distinguished_name=( + 'uid=engineio_admission_ldap_race,dc=example,dc=com' + ), + )) + db.session.commit() + with client.session_transaction() as browser_session: + browser_session['_ldap_verified_at'] = int(time.time()) + + monkeypatch.setattr(config, 'LDAP_ENABLED', True) + monkeypatch.setattr( + ldap_session, + 'revalidate_user', + lambda _user: (_ for _ in ()).throw( + LDAPLookupRejected('simulated directory removal') + ), + ) + original_admission = app_package._engineio_admission_user + validated = threading.Event() + resume = threading.Event() + + def pause_after_validation(flask_app, environ): + admitted_user = original_admission(flask_app, environ) + validated.set() + assert resume.wait(2) + return admitted_user + + monkeypatch.setattr( + app_package, + '_engineio_admission_user', + pause_after_validation, + ) + responses = [] + failures = [] + + def perform_handshake(): + try: + with app.app_context(): + responses.append(_engineio_handshake(client, 'ldap-race')) + except BaseException as error: + failures.append(error) + + worker = threading.Thread(target=perform_handshake) + worker.start() + assert validated.wait(1) + ldap_session.revalidate_all_linked_users(app, socketio) + with app.app_context(): + assert AuthenticationSession.query.filter_by( + user_id=user_id, + ).count() == 0 + resume.set() + worker.join(3) + + assert not worker.is_alive() + assert failures == [] + assert len(responses) == 1 + assert responses[0].status_code == 401 + assert socketio.server.eio.sockets == {} + assert app.extensions['engineio_socket_capacity'].count() == 0 + + +def test_failed_ldap_invalidation_fences_http_and_engineio_until_commit( + app, + monkeypatch, +): + import config + import app.ldap_session as ldap_session + from app import socketio + from app.ldap_service import LDAPLookupRejected + from app.models import AuthenticationSession, User, db + from flask import g + + _create_bootstrap_user(app, 'ldap_commit_admin') + client, user_id = _logged_in_http_client(app, 'ldap_commit_fence') + _mark_user_ldap_managed(app, user_id, 'ldap_commit_fence') + with client.session_transaction() as browser_session: + browser_session['_ldap_verified_at'] = int(time.time()) + replay_client = app.test_client() + session_cookie_name = app.config['SESSION_COOKIE_NAME'] + replay_client.set_cookie( + session_cookie_name, + client.get_cookie(session_cookie_name).value, + ) + + monkeypatch.setattr(config, 'LDAP_ENABLED', True) + monkeypatch.setattr( + ldap_session, + 'revalidate_user', + lambda _user: (_ for _ in ()).throw( + LDAPLookupRejected('simulated directory removal') + ), + ) + revoked = [] + monkeypatch.setattr( + ldap_session.user_lifecycle, + 'revoke_user_access', + lambda owner_id, socketio_instance=None: revoked.append(owner_id), + ) + + real_commit = db.session.commit + commit_attempts = 0 + + def fail_first_two_invalidation_commits(): + nonlocal commit_attempts + commit_attempts += 1 + if commit_attempts <= 2: + raise RuntimeError('transient database failure') + return real_commit() + + monkeypatch.setattr( + db.session, + 'commit', + fail_first_two_invalidation_commits, + ) + + ldap_session.revalidate_all_linked_users(app, socketio) + + with app.app_context(): + assert AuthenticationSession.query.filter_by( + user_id=user_id, + ).count() == 1 + assert db.session.get(User, user_id).auth_generation == 0 + assert ldap_session.ldap_revocation_pending(app, user_id) is True + + # Simulate a complete process restart: no in-memory pending set survives, + # but the replacement app instance must discover the durable marker before + # accepting the still-valid signed cookie. + marker_directory = ( + app.extensions['ldap_revocation_fence']._marker_directory + ) + app.extensions['ldap_revocation_fence'] = ( + ldap_session.LDAPRevocationFence(marker_directory) + ) + assert ldap_session.ldap_revocation_pending(app, user_id) is True + + engineio_response = _engineio_handshake(client, 'ldap-commit-fence') + + assert engineio_response.status_code == 401 + assert socketio.server.eio.sockets == {} + assert app.extensions['engineio_socket_capacity'].count() == 0 + assert ldap_session.ldap_revocation_pending(app, user_id) is True + + g.pop('_login_user', None) + http_response = replay_client.get('/') + + assert http_response.status_code == 302 + assert '/login' in http_response.headers['Location'] + assert commit_attempts == 3 + assert revoked == [user_id, user_id, user_id] + with app.app_context(): + assert AuthenticationSession.query.filter_by( + user_id=user_id, + ).count() == 0 + assert db.session.get(User, user_id).auth_generation == 1 + assert ldap_session.ldap_revocation_pending(app, user_id) is False + assert ldap_session.LDAPRevocationFence( + marker_directory, + ).contains(user_id) is False + + +def test_engineio_safe_get_preprocessing_remains_compatible_with_csrf(app): + from app import socketio + + client, user_id = _logged_in_http_client(app, 'engineio_csrf') + app.config['WTF_CSRF_ENABLED'] = True + + response = _engineio_handshake(client, 'csrf-safe-get') + engineio_sid = _engineio_sid(response) + + try: + assert response.status_code == 200 + assert ( + app.extensions['engineio_socket_capacity'].owner(engineio_sid) + == user_id + ) + assert engineio_sid in socketio.server.eio.sockets + finally: + _close_engineio_socket(engineio_sid) + + assert app.extensions['engineio_socket_capacity'].count() == 0 + + +def test_engineio_rejects_ldap_managed_session_when_ldap_is_disabled(app): + from app import socketio + + _create_bootstrap_user(app, 'engineio_ldap_bootstrap') + username = 'engineio_ldap_disabled' + client, user_id = _logged_in_http_client(app, username) + _mark_user_ldap_managed(app, user_id, username) + + response = _engineio_handshake(client, 'ldap-disabled') + + assert response.status_code == 401 + assert response.data == b'"Unauthorized"' + assert socketio.server.eio.sockets == {} + assert app.extensions['engineio_socket_capacity'].count() == 0 + + +def test_engineio_rejects_failed_due_ldap_revalidation( + app, + monkeypatch, +): + import config + import app.ldap_session as ldap_session + from app import socketio + from app.ldap_service import LDAPUnavailable + + _create_bootstrap_user(app, 'engineio_ldap_failure_bootstrap') + username = 'engineio_ldap_failure' + client, user_id = _logged_in_http_client(app, username) + _mark_user_ldap_managed(app, user_id, username) + revalidated_users = [] + + def fail_revalidation(user): + revalidated_users.append(user.id) + raise LDAPUnavailable('simulated directory outage') + + monkeypatch.setattr(config, 'LDAP_ENABLED', True) + monkeypatch.setattr(ldap_session, 'revalidate_user', fail_revalidation) + + response = _engineio_handshake(client, 'ldap-revalidation-failure') + + assert response.status_code == 401 + assert response.data == b'"Unauthorized"' + assert revalidated_users == [user_id] + assert socketio.server.eio.sockets == {} + assert app.extensions['engineio_socket_capacity'].count() == 0 + + +def test_engineio_accepts_successfully_revalidated_ldap_session( + app, + monkeypatch, +): + import config + import app.ldap_session as ldap_session + from app import socketio + + _create_bootstrap_user(app, 'engineio_ldap_success_bootstrap') + username = 'engineio_ldap_success' + client, user_id = _logged_in_http_client(app, username) + _mark_user_ldap_managed(app, user_id, username) + revalidated_users = [] + monkeypatch.setattr(config, 'LDAP_ENABLED', True) + monkeypatch.setattr( + ldap_session, + 'revalidate_user', + lambda user: revalidated_users.append(user.id), + ) + + response = _engineio_handshake(client, 'ldap-revalidation-success') + engineio_sid = _engineio_sid(response) + + try: + assert response.status_code == 200 + assert revalidated_users == [user_id] + assert ( + app.extensions['engineio_socket_capacity'].owner(engineio_sid) + == user_id + ) + assert engineio_sid in socketio.server.eio.sockets + finally: + _close_engineio_socket(engineio_sid) + + assert app.extensions['engineio_socket_capacity'].count() == 0 + + +def test_due_ldap_validation_coalesces_before_engineio_capacity( + app, + monkeypatch, +): + import config + import app.ldap_session as ldap_session + from app import socketio + from app.models import AuthenticationSession, User, db + + _create_bootstrap_user(app, 'engineio_ldap_coalesce_bootstrap') + username = 'engineio_ldap_coalesce' + seed_client, user_id = _logged_in_http_client(app, username) + _mark_user_ldap_managed(app, user_id, username) + with seed_client.session_transaction() as browser_session: + browser_session['_ldap_verified_at'] = 0 + cookie_name = app.config['SESSION_COOKIE_NAME'] + stale_cookie = seed_client.get_cookie(cookie_name).value + + monkeypatch.setattr(config, 'LDAP_ENABLED', True) + monkeypatch.setattr(config, 'MAX_SOCKET_CONNECTIONS', 4) + monkeypatch.setattr(config, 'MAX_SOCKET_CONNECTIONS_PER_USER', 1) + + lookup_entered = threading.Event() + release_lookup = threading.Event() + lookup_user_ids = [] + + def blocking_revalidation(user): + lookup_user_ids.append(user.id) + lookup_entered.set() + assert release_lookup.wait(5) + + monkeypatch.setattr( + ldap_session, + 'revalidate_user', + blocking_revalidation, + ) + + leader_client = app.test_client() + leader_client.set_cookie(cookie_name, stale_cookie) + leader_responses = [] + failures = [] + + def run_leader(): + try: + with app.app_context(): + leader_responses.append(leader_client.get('/')) + except BaseException as error: + failures.append(error) + + leader = threading.Thread(target=run_leader) + leader.start() + assert lookup_entered.wait(2) + + follower_clients = [app.test_client() for _ in range(4)] + for follower_client in follower_clients: + follower_client.set_cookie(cookie_name, stale_cookie) + follower_responses = [None] * len(follower_clients) + + def run_follower(index): + try: + with app.app_context(): + if index == 0: + follower_responses[index] = follower_clients[index].get('/') + else: + follower_responses[index] = _engineio_handshake( + follower_clients[index], + f'ldap-coalesce-{index}', + ) + except BaseException as error: + failures.append(error) + + followers = [ + threading.Thread(target=run_follower, args=(index,)) + for index in range(len(follower_clients)) + ] + for follower in followers: + follower.start() + + deadline = time.monotonic() + 2 + while any(follower.is_alive() for follower in followers): + remaining = deadline - time.monotonic() + if remaining <= 0: + break + for follower in followers: + follower.join(min(0.02, remaining)) + + followers_finished_before_release = all( + not follower.is_alive() for follower in followers + ) + capacity_while_lookup_blocked = ( + app.extensions['engineio_socket_capacity'].count() + ) + retained_sockets_while_lookup_blocked = len( + socketio.server.eio.sockets + ) + + release_lookup.set() + leader.join(3) + for follower in followers: + follower.join(3) + + assert followers_finished_before_release is True + assert not leader.is_alive() + assert all(not follower.is_alive() for follower in followers) + assert failures == [] + assert lookup_user_ids == [user_id] + assert len(leader_responses) == 1 + assert leader_responses[0].status_code == 200 + assert follower_responses[0].status_code == 503 + assert follower_responses[0].get_json()['code'] == ( + 'ldap_validation_in_progress' + ) + assert follower_responses[0].headers['Retry-After'] == '1' + assert [ + response.status_code for response in follower_responses[1:] + ] == [401, 401, 401] + assert capacity_while_lookup_blocked == 0 + assert retained_sockets_while_lookup_blocked == 0 + + with follower_clients[0].session_transaction() as browser_session: + assert '_user_id' in browser_session + with app.app_context(): + assert AuthenticationSession.query.filter_by( + user_id=user_id, + ).count() == 1 + assert db.session.get(User, user_id).auth_generation == 0 + + # Engine.IO cannot persist the nested Flask session cookie. The shared, + # identity-bound receipt therefore has to make this stale-cookie retry + # cheap while the ordinary socket capacity checks still apply. + retry_client = app.test_client() + retry_client.set_cookie(cookie_name, stale_cookie) + retry = _engineio_handshake(retry_client, 'ldap-coalesce-retry') + engineio_sid = _engineio_sid(retry) + try: + assert retry.status_code == 200 + assert lookup_user_ids == [user_id] + assert ( + app.extensions['engineio_socket_capacity'].owner(engineio_sid) + == user_id + ) + finally: + _close_engineio_socket(engineio_sid) + + assert socketio.server.eio.sockets == {} + assert app.extensions['engineio_socket_capacity'].count() == 0 + + +@pytest.mark.parametrize('state', ('expired', 'locked', 'recovery')) +def test_engineio_rejects_stale_or_restricted_browser_session( + app, + state, + monkeypatch, +): + import app as app_package + from app import socketio + from app.models import AuthenticationSession, User, db + + admission_errors = [] + monkeypatch.setattr( + app_package, + 'log_error', + lambda message, **details: admission_errors.append( + (message, details) + ), + ) + client, user_id = _logged_in_http_client(app, f'engineio_{state}') + with app.app_context(): + if state == 'locked': + user = db.session.get(User, user_id) + user.is_locked = True + else: + auth_session = AuthenticationSession.query.filter_by( + user_id=user_id, + ).one() + if state == 'expired': + auth_session.expires_at = datetime.now(timezone.utc) - timedelta( + seconds=1 + ) + else: + auth_session.methods_json = json.dumps([ + 'password', + 'recovery_code', + ]) + db.session.commit() + + response = _engineio_handshake(client, state) + + assert response.status_code == 401 + assert socketio.server.eio.sockets == {} + assert app.extensions['engineio_socket_capacity'].count() == 0 + assert admission_errors == [] + + +def test_engineio_transport_and_socketio_namespace_keep_separate_lifecycles(app): + from app import socket_events, socketio + from app.models import SocketSession + from app.socket_capacity import socket_capacity + from app.socket_protocol import SOCKET_WIRE_REVISION + + # The session-scoped database template creates an earlier Socket.IO server. + # Rebind decorators to this test app before exercising the real wire path. + importlib.reload(socket_events) + + client, user_id = _logged_in_http_client(app, 'engineio_namespace') + capacity = app.extensions['engineio_socket_capacity'] + response = _engineio_handshake(client, 'namespace') + engineio_sid = _engineio_sid(response) + + try: + assert response.status_code == 200 + assert capacity.owner(engineio_sid) == user_id + assert socket_capacity.count_for_user(user_id) == 0 + + namespace_response = client.post( + f'/socket.io/?EIO=4&transport=polling&sid={engineio_sid}', + base_url=ENGINEIO_BASE_URL, + headers={ + 'Content-Type': 'text/plain;charset=UTF-8', + 'Origin': ENGINEIO_BASE_URL, + }, + data='40' + json.dumps( + {'wire_revision': SOCKET_WIRE_REVISION}, + separators=(',', ':'), + ), + ) + + assert namespace_response.status_code == 200 + assert namespace_response.data == b'OK' + assert socket_capacity.count_for_user(user_id) == 1 + with app.app_context(): + socket_session = SocketSession.query.filter_by( + user_id=user_id, + ).one() + assert socket_session.socket_sid != engineio_sid + finally: + _close_engineio_socket(engineio_sid) + + assert capacity.count() == 0 + assert socket_capacity.count_for_user(user_id) == 0 + with app.app_context(): + assert SocketSession.query.filter_by(user_id=user_id).count() == 0 + + +def test_socketio_namespace_setup_is_linearized_before_user_revocation( + app, + monkeypatch, +): + from app import socket_events, socketio + from app.models import SocketSession, User, db + from app.socket_capacity import socket_capacity + from app.socket_protocol import SOCKET_WIRE_REVISION + from app.user_lifecycle import revoke_user_access + + importlib.reload(socket_events) + client, user_id = _logged_in_http_client( + app, + 'engineio_namespace_revoke_race', + ) + capacity = app.extensions['engineio_socket_capacity'] + response = _engineio_handshake(client, 'namespace-revoke-race') + engineio_sid = _engineio_sid(response) + original_admitted = socket_events._engineio_transport_is_admitted + admitted = threading.Event() + resume = threading.Event() + terminalizing_transport = threading.Event() + + def pause_after_admission(user): + result = original_admitted(user) + assert result is True + admitted.set() + assert resume.wait(2) + return result + + monkeypatch.setattr( + socket_events, + '_engineio_transport_is_admitted', + pause_after_admission, + ) + original_mark_terminal = capacity.mark_terminal + + def tracked_mark_terminal(transport_sid): + terminalizing_transport.set() + return original_mark_terminal(transport_sid) + + monkeypatch.setattr(capacity, 'mark_terminal', tracked_mark_terminal) + namespace_responses = [] + namespace_failures = [] + + def connect_namespace(): + try: + namespace_responses.append(client.post( + f'/socket.io/?EIO=4&transport=polling&sid={engineio_sid}', + base_url=ENGINEIO_BASE_URL, + headers={ + 'Content-Type': 'text/plain;charset=UTF-8', + 'Origin': ENGINEIO_BASE_URL, + }, + data='40' + json.dumps( + {'wire_revision': SOCKET_WIRE_REVISION}, + separators=(',', ':'), + ), + )) + except BaseException as error: + namespace_failures.append(error) + + namespace_worker = threading.Thread(target=connect_namespace) + namespace_worker.start() + assert admitted.wait(1) + + with app.app_context(): + user = db.session.get(User, user_id) + user.is_locked = True + db.session.commit() + + revocation_results = [] + revocation_failures = [] + + def revoke_user(): + try: + with app.app_context(): + revocation_results.append( + revoke_user_access(user_id, socketio) + ) + except BaseException as error: + revocation_failures.append(error) + + revocation_worker = threading.Thread(target=revoke_user) + revocation_worker.start() + assert terminalizing_transport.wait(1) + assert revocation_worker.is_alive() + + resume.set() + namespace_worker.join(3) + revocation_worker.join(3) + + assert not namespace_worker.is_alive() + assert not revocation_worker.is_alive() + assert namespace_failures == [] + assert revocation_failures == [] + assert len(namespace_responses) == 1 + assert namespace_responses[0].status_code == 200 + assert len(revocation_results) == 1 + assert engineio_sid not in socketio.server.eio.sockets + assert capacity.count() == 0 + assert socket_capacity.count_for_user(user_id) == 0 + with app.app_context(): + assert SocketSession.query.filter_by(user_id=user_id).count() == 0 + + +def test_rejected_namespace_drains_error_then_releases_engineio(app): + from app import socket_events, socketio + from app.models import SocketSession + from app.socket_capacity import socket_capacity + from app.socket_protocol import SOCKET_WIRE_REVISION + + importlib.reload(socket_events) + client, user_id = _logged_in_http_client( + app, + 'engineio_rejected_namespace', + ) + capacity = app.extensions['engineio_socket_capacity'] + response = _engineio_handshake(client, 'rejected-namespace') + engineio_sid = _engineio_sid(response) + + try: + rejected = client.post( + f'/socket.io/?EIO=4&transport=polling&sid={engineio_sid}', + base_url=ENGINEIO_BASE_URL, + headers={ + 'Content-Type': 'text/plain;charset=UTF-8', + 'Origin': ENGINEIO_BASE_URL, + }, + data='40' + json.dumps( + {'wire_revision': SOCKET_WIRE_REVISION - 1}, + separators=(',', ':'), + ), + ) + + assert rejected.status_code == 200 + assert rejected.data == b'OK' + assert capacity.is_terminal(engineio_sid) is True + + # A racing retry on the same admitted transport must not bind after + # it was terminalized by the first rejected namespace. + retried = client.post( + f'/socket.io/?EIO=4&transport=polling&sid={engineio_sid}', + base_url=ENGINEIO_BASE_URL, + headers={ + 'Content-Type': 'text/plain;charset=UTF-8', + 'Origin': ENGINEIO_BASE_URL, + }, + data='40' + json.dumps( + {'wire_revision': SOCKET_WIRE_REVISION}, + separators=(',', ':'), + ), + ) + assert retried.status_code == 200 + assert retried.data == b'OK' + assert socket_capacity.count_for_user(user_id) == 0 + + error_response = client.get( + f'/socket.io/?EIO=4&transport=polling&sid={engineio_sid}', + base_url=ENGINEIO_BASE_URL, + headers={'Origin': ENGINEIO_BASE_URL}, + ) + assert error_response.status_code == 200 + assert b'socket_protocol_mismatch' in error_response.data + + deadline = time.monotonic() + 2 + while ( + engineio_sid in socketio.server.eio.sockets + and time.monotonic() < deadline + ): + time.sleep(0.01) + + assert engineio_sid not in socketio.server.eio.sockets + assert capacity.count() == 0 + assert capacity.is_terminal(engineio_sid) is False + rejected_pong = client.post( + f'/socket.io/?EIO=4&transport=polling&sid={engineio_sid}', + base_url=ENGINEIO_BASE_URL, + headers={ + 'Content-Type': 'text/plain;charset=UTF-8', + 'Origin': ENGINEIO_BASE_URL, + }, + data='3', + ) + assert rejected_pong.status_code == 400 + finally: + if engineio_sid in socketio.server.eio.sockets: + _close_engineio_socket(engineio_sid) + + with app.app_context(): + assert SocketSession.query.filter_by(user_id=user_id).count() == 0 + + +def test_rejected_namespace_cleanup_survives_namespace_disconnect( + app, + monkeypatch, +): + from app import socket_events, socketio + from app.socket_protocol import SOCKET_WIRE_REVISION + + importlib.reload(socket_events) + client, _user_id = _logged_in_http_client( + app, + 'engineio_namespace_auth_race', + ) + capacity = app.extensions['engineio_socket_capacity'] + response = _engineio_handshake(client, 'namespace-auth-race') + engineio_sid = _engineio_sid(response) + monkeypatch.setattr(socket_events, 'load_user', lambda _user_id: None) + + try: + rejected = client.post( + f'/socket.io/?EIO=4&transport=polling&sid={engineio_sid}', + base_url=ENGINEIO_BASE_URL, + headers={ + 'Content-Type': 'text/plain;charset=UTF-8', + 'Origin': ENGINEIO_BASE_URL, + }, + data='40' + json.dumps( + {'wire_revision': SOCKET_WIRE_REVISION}, + separators=(',', ':'), + ), + ) + + assert rejected.status_code == 200 + assert rejected.data == b'OK' + assert socketio.server.manager.sid_from_eio_sid( + engineio_sid, + '/', + ) is None + assert capacity.is_terminal(engineio_sid) is True + + deadline = time.monotonic() + 2 + while ( + engineio_sid in socketio.server.eio.sockets + and time.monotonic() < deadline + ): + time.sleep(0.01) + + assert engineio_sid not in socketio.server.eio.sockets + assert capacity.count() == 0 + rejected_pong = client.post( + f'/socket.io/?EIO=4&transport=polling&sid={engineio_sid}', + base_url=ENGINEIO_BASE_URL, + headers={ + 'Content-Type': 'text/plain;charset=UTF-8', + 'Origin': ENGINEIO_BASE_URL, + }, + data='3', + ) + assert rejected_pong.status_code == 400 + finally: + if engineio_sid in socketio.server.eio.sockets: + _close_engineio_socket(engineio_sid) + + +def test_server_disconnect_retires_the_exact_engineio_transport(app): + from app import socket_events, socketio + from app.models import SocketSession + from app.socket_capacity import socket_capacity + from app.socket_protocol import SOCKET_WIRE_REVISION + + importlib.reload(socket_events) + client, user_id = _logged_in_http_client( + app, + 'engineio_forced_disconnect', + ) + capacity = app.extensions['engineio_socket_capacity'] + response = _engineio_handshake(client, 'forced-disconnect') + engineio_sid = _engineio_sid(response) + + try: + connected = client.post( + f'/socket.io/?EIO=4&transport=polling&sid={engineio_sid}', + base_url=ENGINEIO_BASE_URL, + headers={ + 'Content-Type': 'text/plain;charset=UTF-8', + 'Origin': ENGINEIO_BASE_URL, + }, + data='40' + json.dumps( + {'wire_revision': SOCKET_WIRE_REVISION}, + separators=(',', ':'), + ), + ) + assert connected.status_code == 200 + + namespace_sid = socketio.server.manager.sid_from_eio_sid( + engineio_sid, + '/', + ) + assert namespace_sid is not None + assert socket_capacity.count_for_user(user_id) == 1 + + assert socket_events.disconnect_socket_transport( + socketio.server, + namespace_sid, + ) is True + assert capacity.is_terminal(engineio_sid) is True + + drained = client.get( + f'/socket.io/?EIO=4&transport=polling&sid={engineio_sid}', + base_url=ENGINEIO_BASE_URL, + headers={'Origin': ENGINEIO_BASE_URL}, + ) + assert drained.status_code == 200 + assert b'41' in drained.data + + deadline = time.monotonic() + 2 + while ( + engineio_sid in socketio.server.eio.sockets + and time.monotonic() < deadline + ): + time.sleep(0.01) + + assert engineio_sid not in socketio.server.eio.sockets + assert capacity.count() == 0 + assert socket_capacity.count_for_user(user_id) == 0 + rejected_pong = client.post( + f'/socket.io/?EIO=4&transport=polling&sid={engineio_sid}', + base_url=ENGINEIO_BASE_URL, + headers={ + 'Content-Type': 'text/plain;charset=UTF-8', + 'Origin': ENGINEIO_BASE_URL, + }, + data='3', + ) + assert rejected_pong.status_code == 400 + finally: + if engineio_sid in socketio.server.eio.sockets: + _close_engineio_socket(engineio_sid) + + with app.app_context(): + assert SocketSession.query.filter_by(user_id=user_id).count() == 0 + + +def test_stale_socket_auth_drains_error_and_retires_transport(app): + from app import socket_events, socketio + from app.models import AuthenticationSession, SocketSession, db + from app.socket_capacity import socket_capacity + from app.socket_protocol import SOCKET_WIRE_REVISION + + importlib.reload(socket_events) + client, user_id = _logged_in_http_client(app, 'engineio_stale_auth') + capacity = app.extensions['engineio_socket_capacity'] + response = _engineio_handshake(client, 'stale-auth') + engineio_sid = _engineio_sid(response) + + try: + connected = client.post( + f'/socket.io/?EIO=4&transport=polling&sid={engineio_sid}', + base_url=ENGINEIO_BASE_URL, + headers={ + 'Content-Type': 'text/plain;charset=UTF-8', + 'Origin': ENGINEIO_BASE_URL, + }, + data='40' + json.dumps( + {'wire_revision': SOCKET_WIRE_REVISION}, + separators=(',', ':'), + ), + ) + assert connected.status_code == 200 + + initial_events = client.get( + f'/socket.io/?EIO=4&transport=polling&sid={engineio_sid}', + base_url=ENGINEIO_BASE_URL, + headers={'Origin': ENGINEIO_BASE_URL}, + ) + assert initial_events.status_code == 200 + assert b'connected' in initial_events.data + + with app.app_context(): + AuthenticationSession.query.filter_by(user_id=user_id).delete() + db.session.commit() + + rejected = client.post( + f'/socket.io/?EIO=4&transport=polling&sid={engineio_sid}', + base_url=ENGINEIO_BASE_URL, + headers={ + 'Content-Type': 'text/plain;charset=UTF-8', + 'Origin': ENGINEIO_BASE_URL, + }, + data='42' + json.dumps( + ['cancel_directory_listing', {}], + separators=(',', ':'), + ), + ) + assert rejected.status_code == 200 + + drained = client.get( + f'/socket.io/?EIO=4&transport=polling&sid={engineio_sid}', + base_url=ENGINEIO_BASE_URL, + headers={'Origin': ENGINEIO_BASE_URL}, + ) + assert drained.status_code == 200 + assert b'authentication_required' in drained.data + assert b'41' in drained.data + + deadline = time.monotonic() + 2 + while ( + engineio_sid in socketio.server.eio.sockets + and time.monotonic() < deadline + ): + time.sleep(0.01) + + assert engineio_sid not in socketio.server.eio.sockets + assert capacity.count() == 0 + assert socket_capacity.count_for_user(user_id) == 0 + rejected_pong = client.post( + f'/socket.io/?EIO=4&transport=polling&sid={engineio_sid}', + base_url=ENGINEIO_BASE_URL, + headers={ + 'Content-Type': 'text/plain;charset=UTF-8', + 'Origin': ENGINEIO_BASE_URL, + }, + data='3', + ) + assert rejected_pong.status_code == 400 + finally: + if engineio_sid in socketio.server.eio.sockets: + _close_engineio_socket(engineio_sid) + + with app.app_context(): + assert SocketSession.query.filter_by(user_id=user_id).count() == 0 + + +def test_engineio_cleanup_scheduling_failure_releases_exact_capacity_slot(): + from types import SimpleNamespace + + from app import socket_events + from app.socket_capacity import SocketCapacityRegistry + + capacity = SocketCapacityRegistry() + assert capacity.reserve(7, 'engineio-exact', 10, 10) is True + assert capacity.mark_terminal('engineio-exact') == 7 + + closed = [] + engineio_socket = SimpleNamespace( + close=lambda **kwargs: closed.append(kwargs), + ) + + class FailingEngineIOServer: + sockets = {'engineio-exact': engineio_socket} + reason = SimpleNamespace(SERVER_DISCONNECT='server disconnect') + + @staticmethod + def start_background_task(_target): + raise RuntimeError('simulated scheduler failure') + + engineio_server = FailingEngineIOServer() + cleanup_context = ( + object(), + object(), + engineio_server, + 'engineio-exact', + engineio_socket, + capacity, + 7, + ) + + socket_events._schedule_engineio_cleanup(cleanup_context) + + assert closed == [{ + 'wait': False, + 'abort': True, + 'reason': 'server disconnect', + }] + assert engineio_server.sockets == {} + assert capacity.count() == 0 + + +def test_engineio_handshake_exception_releases_exact_socket_and_capacity( + app, + monkeypatch, +): + from app import socketio + + warm_client, _warm_user_id = _logged_in_http_client( + app, + 'engineio_exception_warmup', + ) + warm_response = _engineio_handshake(warm_client, 'exception-warmup') + warm_sid = _engineio_sid(warm_response) + _close_engineio_socket(warm_sid) + + client, user_id = _logged_in_http_client( + app, + 'engineio_handshake_exception', + ) + monkeypatch.setattr( + socketio.server.eio, + 'start_background_task', + lambda _target: (_ for _ in ()).throw( + RuntimeError('simulated ping scheduler failure') + ), + ) + + with pytest.raises(RuntimeError, match='ping scheduler failure'): + _engineio_handshake(client, 'handshake-exception') + + capacity = app.extensions['engineio_socket_capacity'] + assert socketio.server.eio.sockets == {} + assert capacity.count() == 0 + assert capacity.count_for_user(user_id) == 0 + + +def test_engineio_cleanup_is_safe_after_a_natural_close_race(): + from types import SimpleNamespace + + from app import socket_events + from app.socket_capacity import SocketCapacityRegistry + + capacity = SocketCapacityRegistry() + assert capacity.reserve(8, 'engineio-race', 10, 10) is True + assert capacity.mark_terminal('engineio-race') == 8 + engineio_socket = SimpleNamespace( + close=lambda **_kwargs: pytest.fail('already-closed socket reused'), + ) + + class RacingEngineIOServer: + sockets = {'engineio-race': engineio_socket} + reason = SimpleNamespace(SERVER_DISCONNECT='server disconnect') + + def start_background_task(self, target): + self.sockets.pop('engineio-race') + capacity.release('engineio-race') + target() + + engineio_server = RacingEngineIOServer() + cleanup_context = ( + object(), + object(), + engineio_server, + 'engineio-race', + engineio_socket, + capacity, + 8, + ) + + socket_events._schedule_engineio_cleanup(cleanup_context, drain=False) + + assert engineio_server.sockets == {} + assert capacity.count() == 0 + + +def test_user_revocation_retires_pre_namespace_engineio_transport(app): + from app import socketio + from app.models import AuthenticationSession + from app.socket_protocol import SOCKET_WIRE_REVISION + from app.user_lifecycle import revoke_user_access + + client, user_id = _logged_in_http_client( + app, + 'engineio_pre_namespace_revoke', + ) + capacity = app.extensions['engineio_socket_capacity'] + response = _engineio_handshake(client, 'pre-namespace-revoke') + engineio_sid = _engineio_sid(response) + + try: + assert capacity.owner(engineio_sid) == user_id + assert socketio.server.manager.sid_from_eio_sid( + engineio_sid, + '/', + ) is None + + with app.app_context(): + # Background LDAP rejection retains the browser assurance row; + # revocation itself must still invalidate an already-admitted EIO + # transport before it can establish a Socket.IO namespace. + assert AuthenticationSession.query.filter_by( + user_id=user_id, + ).count() == 1 + revoke_user_access(user_id, socketio) + + deadline = time.monotonic() + 2 + while ( + engineio_sid in socketio.server.eio.sockets + and time.monotonic() < deadline + ): + time.sleep(0.01) + + assert engineio_sid not in socketio.server.eio.sockets + assert capacity.count() == 0 + rejected_namespace = client.post( + f'/socket.io/?EIO=4&transport=polling&sid={engineio_sid}', + base_url=ENGINEIO_BASE_URL, + headers={ + 'Content-Type': 'text/plain;charset=UTF-8', + 'Origin': ENGINEIO_BASE_URL, + }, + data='40' + json.dumps( + {'wire_revision': SOCKET_WIRE_REVISION}, + separators=(',', ':'), + ), + ) + assert rejected_namespace.status_code == 400 + finally: + if engineio_sid in socketio.server.eio.sockets: + _close_engineio_socket(engineio_sid) + + +def test_restore_disconnect_retires_pre_namespace_engineio_transport(app): + from app import socketio + from app.restore_service import _disconnect_sockets + + client, user_id = _logged_in_http_client( + app, + 'engineio_pre_namespace_restore', + ) + capacity = app.extensions['engineio_socket_capacity'] + response = _engineio_handshake(client, 'pre-namespace-restore') + engineio_sid = _engineio_sid(response) + + try: + assert capacity.owner(engineio_sid) == user_id + assert socketio.server.manager.sid_from_eio_sid( + engineio_sid, + '/', + ) is None + + _disconnect_sockets(socketio) + + deadline = time.monotonic() + 2 + while ( + engineio_sid in socketio.server.eio.sockets + and time.monotonic() < deadline + ): + time.sleep(0.01) + + assert engineio_sid not in socketio.server.eio.sockets + assert capacity.count() == 0 + rejected_pong = client.post( + f'/socket.io/?EIO=4&transport=polling&sid={engineio_sid}', + base_url=ENGINEIO_BASE_URL, + headers={ + 'Content-Type': 'text/plain;charset=UTF-8', + 'Origin': ENGINEIO_BASE_URL, + }, + data='3', + ) + assert rejected_pong.status_code == 400 + finally: + if engineio_sid in socketio.server.eio.sockets: + _close_engineio_socket(engineio_sid) + + +def test_engineio_capacity_snapshots_exact_owned_transport_ids(): + from app.socket_capacity import SocketCapacityRegistry + + capacity = SocketCapacityRegistry() + assert capacity.reserve(7, 'first', 10, 10) is True + assert capacity.reserve(7, 'second', 10, 10) is True + assert capacity.reserve(8, 'other', 10, 10) is True + + assert set(capacity.sids_for_user(7)) == {'first', 'second'} + assert set(capacity.sids()) == {'first', 'second', 'other'} + + capacity.release('first') + + assert capacity.sids_for_user(7) == ('second',) + assert set(capacity.sids()) == {'second', 'other'} + + +def test_engineio_admission_guard_only_blocks_the_same_transport(): + from app.socket_capacity import SocketCapacityRegistry + + capacity = SocketCapacityRegistry() + assert capacity.reserve(7, 'guarded', 10, 10) is True + assert capacity.reserve(8, 'independent', 10, 10) is True + + guard_entered = threading.Event() + release_guard = threading.Event() + + def hold_guard(): + with capacity.admission_guard('guarded', 7) as admitted: + assert admitted is True + guard_entered.set() + assert release_guard.wait(timeout=2) + + guard_thread = threading.Thread(target=hold_guard) + guard_thread.start() + assert guard_entered.wait(timeout=2) + + independent_result = [] + independent_done = threading.Event() + + def terminalize_independent(): + independent_result.append(capacity.mark_terminal('independent')) + independent_done.set() + + independent_thread = threading.Thread(target=terminalize_independent) + independent_thread.start() + assert independent_done.wait(timeout=2) + assert independent_result == [8] + + guarded_result = [] + guarded_done = threading.Event() + + def terminalize_guarded(): + guarded_result.append(capacity.mark_terminal('guarded')) + guarded_done.set() + + guarded_thread = threading.Thread(target=terminalize_guarded) + guarded_thread.start() + assert guarded_done.wait(timeout=0.05) is False + + release_guard.set() + guard_thread.join(timeout=2) + guarded_thread.join(timeout=2) + independent_thread.join(timeout=2) + + assert guard_thread.is_alive() is False + assert guarded_thread.is_alive() is False + assert independent_thread.is_alive() is False + assert guarded_result == [7] + + +def test_engineio_capacity_applies_before_socketio_namespace_connect( + app, + monkeypatch, +): + import config + from app import socketio + from app.socket_capacity import socket_capacity + + monkeypatch.setattr(config, 'MAX_SOCKET_CONNECTIONS', 2, raising=False) + monkeypatch.setattr( + config, + 'MAX_SOCKET_CONNECTIONS_PER_USER', + 1, + raising=False, + ) + first_client, first_user_id = _logged_in_http_client( + app, + 'engineio_capacity_first', + ) + second_client, second_user_id = _logged_in_http_client( + app, + 'engineio_capacity_second', + ) + third_client, _third_user_id = _logged_in_http_client( + app, + 'engineio_capacity_third', + ) + + first_response = _engineio_handshake(first_client, 'capacity-first') + first_sid = _engineio_sid(first_response) + second_sid = None + try: + same_user_response = _engineio_handshake( + first_client, + 'capacity-same-user', + ) + second_response = _engineio_handshake(second_client, 'capacity-second') + second_sid = _engineio_sid(second_response) + over_global_response = _engineio_handshake( + third_client, + 'capacity-global', + ) + + capacity = app.extensions['engineio_socket_capacity'] + assert first_response.status_code == 200 + assert same_user_response.status_code == 401 + assert second_response.status_code == 200 + assert over_global_response.status_code == 401 + assert capacity.count() == 2 + assert capacity.count_for_user(first_user_id) == 1 + assert capacity.count_for_user(second_user_id) == 1 + assert len(socketio.server.eio.sockets) == 2 + assert socket_capacity.count() == 0 + finally: + _close_engineio_socket(first_sid) + if second_sid is not None: + _close_engineio_socket(second_sid) + + assert app.extensions['engineio_socket_capacity'].count() == 0 + + +def test_missing_engineio_transport_is_only_allowed_for_testing_adapter( + app, + monkeypatch, +): + from flask import request + + from app import socket_events, socketio + + missing_sid = 'missing-engineio-transport' + assert missing_sid not in socketio.server.eio.sockets + monkeypatch.setattr( + socketio.server.manager, + 'eio_sid_from_sid', + lambda _sid, _namespace: missing_sid, + ) + user = type('User', (), {'id': 1})() + + with app.test_request_context('/socket.io'): + request.sid = 'test-namespace-sid' + app.config['TESTING'] = False + assert socket_events._engineio_transport_is_admitted(user) is False + + app.config['TESTING'] = True + assert socket_events._engineio_transport_is_admitted(user) is True + + def test_synchronous_socketio_handler_never_queues_an_unbounded_task(): """A saturated caller waits in its worker instead of creating another thread.""" import socketio as python_socketio @@ -83,7 +1744,7 @@ def test_synchronous_socketio_handler_never_queues_an_unbounded_task(): assert observed_threads == [caller_thread] -@pytest.mark.parametrize('thread_count', ['0', '1', '7', '257', 'invalid']) +@pytest.mark.parametrize('thread_count', ['7', '257']) def test_gunicorn_threads_rejects_values_outside_the_safe_range(thread_count): """An unbounded gthread worker could exhaust process memory under load.""" result = _config_probe(thread_count) @@ -139,104 +1800,3 @@ def test_socket_capacity_rejects_configuration_without_http_reserve(): assert result.returncode != 0 assert 'at least 4 Gunicorn threads available for HTTP' in result.stderr - - -def test_playwright_uses_the_configured_e2e_port(): - """A shared workstation must not force browser tests onto port 4173.""" - environment = os.environ.copy() - port = str(_free_loopback_port()) - environment['WEBSSH_E2E_PORT'] = port - result = subprocess.run( - [ - 'node', - '-e', - "console.log(require('./playwright.config').use.baseURL)", - ], - cwd=PROJECT_ROOT, - env=environment, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr - assert result.stdout.strip() == f'http://127.0.0.1:{port}' - - -def test_e2e_runner_listens_on_the_configured_port(): - """The browser server and its base URL must select the same free port.""" - environment = os.environ.copy() - port = _free_loopback_port() - environment['WEBSSH_E2E_PORT'] = str(port) - process = subprocess.Popen( - [sys.executable, 'tests/e2e/run_app.py'], - cwd=PROJECT_ROOT, - env=environment, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - deadline = time.monotonic() + 15 - try: - while time.monotonic() < deadline: - if process.poll() is not None: - break - try: - with urllib.request.urlopen( - f'http://127.0.0.1:{port}/login', timeout=1) as response: - assert response.status == 200 - return - except OSError: - time.sleep(0.1) - raise AssertionError('E2E runner did not listen on WEBSSH_E2E_PORT') - finally: - if process.poll() is None: - process.terminate() - process.wait(timeout=5) - - -def test_e2e_runner_accepts_a_socketio_handshake_on_its_configured_origin(): - """A custom E2E port must update CORS as well as the HTTP base URL.""" - environment = os.environ.copy() - port = _free_loopback_port() - environment['WEBSSH_E2E_PORT'] = str(port) - base_url = f'http://127.0.0.1:{port}' - process = subprocess.Popen( - [sys.executable, 'tests/e2e/run_app.py'], - cwd=PROJECT_ROOT, - env=environment, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - deadline = time.monotonic() + 15 - try: - while time.monotonic() < deadline: - if process.poll() is not None: - break - try: - with urllib.request.urlopen( - f'{base_url}/login', timeout=1) as response: - if response.status == 200: - break - except OSError: - time.sleep(0.1) - else: - raise AssertionError('E2E runner did not start for Socket.IO test') - - handshake_url = ( - f'{base_url}/socket.io/?EIO=4&transport=polling&t=threading-test' - ) - try: - with urllib.request.urlopen(urllib.request.Request( - handshake_url, - headers={'Origin': base_url}), timeout=5) as response: - assert response.status == 200 - assert response.headers['Access-Control-Allow-Origin'] == base_url - assert response.read().startswith(b'0{') - except urllib.error.HTTPError as error: - raise AssertionError( - f'Socket.IO handshake rejected configured origin: {error}' - ) from error - finally: - if process.poll() is None: - process.terminate() - process.wait(timeout=5) diff --git a/tests/test_transfer_cancellation.py b/tests/test_transfer_cancellation.py index 8e33f57a..01041b5a 100644 --- a/tests/test_transfer_cancellation.py +++ b/tests/test_transfer_cancellation.py @@ -792,6 +792,61 @@ def progress(transferred): assert list(tmp_path.iterdir()) == [] +def test_fallback_zip_chmod_failure_removes_temporary_archive( + tmp_path, + monkeypatch, +): + from app import sftp_handler + + real_chmod = os.chmod + + def reject_archive_chmod(path, mode): + if mode == 0o600: + raise OSError('chmod unavailable') + return real_chmod(path, mode) + + monkeypatch.setattr(sftp_handler.os, 'chmod', reject_archive_chmod) + + with pytest.raises(OSError, match='chmod unavailable'): + sftp_handler.build_fallback_zip_to_disk( + None, + '/reports', + 'reports', + cancel_event=threading.Event(), + max_bytes=1024, + chunk_size=4, + temp_dir=tmp_path, + ) + + assert list(tmp_path.iterdir()) == [] + + +def test_fallback_zip_base_exception_removes_temporary_archive( + tmp_path, + monkeypatch, +): + from app import sftp_handler + + monkeypatch.setattr( + sftp_handler.zipfile, + 'ZipFile', + lambda *_args, **_kwargs: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + + with pytest.raises(KeyboardInterrupt): + sftp_handler.build_fallback_zip_to_disk( + None, + '/reports', + 'reports', + cancel_event=threading.Event(), + max_bytes=1024, + chunk_size=4, + temp_dir=tmp_path, + ) + + assert list(tmp_path.iterdir()) == [] + + def test_empty_directory_entries_cannot_exceed_reserved_zip_bytes(tmp_path): from app.sftp_handler import TransferSizeExceeded, build_fallback_zip_to_disk diff --git a/tests/test_transfer_errors.py b/tests/test_transfer_errors.py index 63d92344..b47d9cf5 100644 --- a/tests/test_transfer_errors.py +++ b/tests/test_transfer_errors.py @@ -2,6 +2,7 @@ import pytest +from app.file_backend import FileOperationCancelled, FileSourceChanged from app.remote_transfer import ( RemoteTransferCancelled, RemoteTransferConflict, @@ -79,6 +80,14 @@ 404, True, ), + ( + FileSourceChanged(r'secret \\server\share'), + 'download', + 'SOURCE_CHANGED', + 'The source changed during the transfer. Try again.', + 409, + True, + ), ( RemoteTransferLimitExceeded('sensitive limit details'), 'remote_transfer', @@ -95,6 +104,14 @@ 409, False, ), + ( + FileOperationCancelled('sensitive cancellation details'), + 'folder_download', + 'CANCELLED', + 'The transfer was cancelled.', + 409, + False, + ), ( NonAtomicOverwriteRequired('sensitive target'), 'upload', diff --git a/tests/test_transfer_routes.py b/tests/test_transfer_routes.py index b71c7f17..75ecbe21 100644 --- a/tests/test_transfer_routes.py +++ b/tests/test_transfer_routes.py @@ -1675,33 +1675,45 @@ def test_smb_folder_download_builds_bounded_local_zip_via_backend( transfer_routes, manager = transfer_components payload = b'encrypted-smb-folder-body' + root_identity_chain = (17,) class Backend: def stat(self, _source, path, *, follow_links=False): assert follow_links is False if path == '/reports': - return {'size': 0, 'is_dir': True, 'is_symlink': False}, None + return { + 'size': 0, + 'is_dir': True, + 'is_symlink': False, + '_smb_identity_chain': root_identity_chain, + }, None return { 'size': len(payload), 'is_dir': False, 'is_symlink': False, }, None def iter_tree( self, _source, path, *, budget, cancel_event, - follow_links=False, io_lane='control'): + follow_links=False, io_lane='control', + _expected_identities=None): assert path == '/reports' assert isinstance(budget, TransferBudget) assert follow_links is False assert io_lane == 'transfer' + assert _expected_identities == root_identity_chain budget.consume() yield { 'name': 'report.txt', 'path': '/reports/report.txt', 'size': len(payload), 'is_dir': False, 'is_symlink': False, + '_smb_identity_chain': (17, 23), } @contextmanager - def open_reader(self, _source, path, *, io_lane='control'): + def open_reader( + self, _source, path, *, io_lane='control', + _expected_identities=None): assert path == '/reports/report.txt' assert io_lane == 'transfer' + assert _expected_identities == (17, 23) with TrackingRemoteFile(payload) as remote: yield FileReaderLease(reader=remote, size=len(payload)) @@ -1737,6 +1749,62 @@ def open_reader(self, _source, path, *, io_lane='control'): assert manager._records == {} +def test_backend_zip_chmod_failure_removes_temporary_archive( + tmp_path, + monkeypatch, +): + from app import transfer_routes + + def reject_chmod(_path, mode): + assert mode == 0o600 + raise OSError('chmod unavailable') + + monkeypatch.setattr(transfer_routes.Path, 'chmod', reject_chmod) + + with pytest.raises(OSError, match='chmod unavailable'): + transfer_routes._build_backend_zip_to_disk( + SimpleNamespace(backend=None), + '/reports', + 'reports', + cancel_event=SimpleNamespace(is_set=lambda: False), + max_bytes=1024, + chunk_size=4, + temp_dir=tmp_path, + ) + + assert list(tmp_path.iterdir()) == [] + + +def test_backend_zip_base_exception_removes_temporary_archive( + tmp_path, + monkeypatch, +): + from app import transfer_routes + + monkeypatch.setattr( + transfer_routes.zipfile, + 'ZipFile', + lambda *_args, **_kwargs: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + + with pytest.raises(KeyboardInterrupt): + transfer_routes._build_backend_zip_to_disk( + SimpleNamespace( + backend=SimpleNamespace( + iter_tree=lambda *_args, **_kwargs: [] + ) + ), + '/reports', + 'reports', + cancel_event=SimpleNamespace(is_set=lambda: False), + max_bytes=1024, + chunk_size=4, + temp_dir=tmp_path, + ) + + assert list(tmp_path.iterdir()) == [] + + def test_folder_download_preflight_reports_smb_permission_failure( app, client, monkeypatch, transfer_components): import app as app_package @@ -1797,6 +1865,74 @@ def stat_or_raise(self, *_args, **_kwargs): assert 'private' not in repr(response.get_json()) +def test_smb_folder_enumeration_cancellation_is_reported_as_cancelled( + app, client, monkeypatch, transfer_components): + import app as app_package + from app.file_backend import FileOperationCancelled + + transfer_routes, manager = transfer_components + + class Backend: + def stat(self, _source, _path, *, follow_links=False): + assert follow_links is False + return {'size': 0, 'is_dir': True, 'is_symlink': False}, None + + def iter_tree( + self, _source, _path, *, budget, cancel_event, + follow_links=False, io_lane='control'): + assert follow_links is False + assert io_lane == 'transfer' + raise FileOperationCancelled('private backend detail') + yield # pragma: no cover - generator contract + + resolved = SimpleNamespace( + handle_id='smb-handle', backend=Backend(), source_id='smb-quick:owned', + ) + monkeypatch.setattr( + transfer_routes.file_service, 'resolve', + lambda *_args, **_kwargs: resolved, + ) + monkeypatch.setattr( + transfer_routes, '_audit_transfer_source', lambda *_args, **_kwargs: None + ) + emitted = [] + monkeypatch.setattr( + app_package.socketio, + 'emit', + lambda event, payload, **kwargs: emitted.append((event, payload, kwargs)), + ) + user_id = _login(client, app, 'folder_enumeration_cancelled') + record = manager.create( + user_id=user_id, + source_id='smb-quick:owned', + direction='download', + metadata={ + 'remote_path': '/reports', 'filename': 'reports', 'archive': True, + }, + ) + + response = client.get(f'/api/transfers/{record.token}/folder-download') + + expected = { + 'error_code': 'CANCELLED', + 'error': 'The transfer was cancelled.', + 'retryable': False, + } + assert response.status_code == 409 + assert response.get_json() == expected + assert emitted == [( + 'transfer_finished', + { + 'transfer_id': record.transfer_id, + 'direction': 'download', + 'status': 'cancelled', + **expected, + }, + {'room': f'user_{user_id}'}, + )] + assert record.request_done_event.is_set() + + def test_folder_download_rejects_oversized_opened_archive_before_first_chunk( app, client, monkeypatch, transfer_components): transfer_routes, manager = transfer_components diff --git a/tests/test_xterm_vendor_policy.py b/tests/test_xterm_vendor_policy.py new file mode 100644 index 00000000..9a3b53e0 --- /dev/null +++ b/tests/test_xterm_vendor_policy.py @@ -0,0 +1,18 @@ +"""Security invariants applied to deterministic vendored xterm output.""" + +from pathlib import Path + + +ROOT = Path(__file__).parents[1] + + +def test_xterm_control_string_parser_limit_is_reduced_deterministically(): + vendor_script = (ROOT / 'scripts' / 'vendor.js').read_text(encoding='utf-8') + vendored_xterm = ( + ROOT / 'static' / 'vendor' / 'xterm' / 'xterm.js' + ).read_text(encoding='utf-8') + + assert "const parserLimit = 't.PAYLOAD_LIMIT=1e7'" in vendor_script + assert "const boundedParserLimit = 't.PAYLOAD_LIMIT=2e5'" in vendor_script + assert vendored_xterm.count('t.PAYLOAD_LIMIT=2e5') == 1 + assert 't.PAYLOAD_LIMIT=1e7' not in vendored_xterm