Files
wehub-resource-sync e0e362d700
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
SDK Tests / SDK CI (push) Has been cancelled
SDK Tests / CLI Tests (push) Has been cancelled
SDK Tests / Python SDK Quality (code-interpreter) (push) Has been cancelled
SDK Tests / Python SDK Quality (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (sandbox) (push) Has been cancelled
SDK Tests / CLI Quality (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Go SDK Quality And Tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:39:33 +08:00

529 lines
20 KiB
Python

# Copyright 2025 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""OSSFS-specific Docker runtime behaviors."""
from __future__ import annotations
import logging
import os
import posixpath
import re
import subprocess
import tempfile
from typing import Any, Optional
from uuid import uuid4
from fastapi import HTTPException, status
from opensandbox_server.services.constants import SandboxErrorCodes
from opensandbox_server.services.helpers import normalize_external_endpoint_url
logger = logging.getLogger(__name__)
class OSSFSMixin:
@staticmethod
def _validate_bucket_name(bucket: str) -> None:
"""
Validate OSS bucket name to prevent command injection.
Bucket names must follow OSS naming rules: lowercase letters, numbers, hyphens.
Length: 3-63 characters. Cannot start/end with hyphen.
"""
if not bucket or not isinstance(bucket, str):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": SandboxErrorCodes.INVALID_PARAMETER,
"message": "OSSFS bucket name cannot be empty.",
},
)
# OSS bucket naming: 3-63 chars, lowercase alphanumeric and hyphens only
# Must start and end with lowercase letter or digit
if not re.match(r'^[a-z0-9]([a-z0-9-]{1,61}[a-z0-9])?$', bucket):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": SandboxErrorCodes.INVALID_PARAMETER,
"message": (
f"Invalid bucket name '{bucket}'. Bucket names must be 3-63 characters, "
"contain only lowercase letters, numbers, and hyphens, "
"and start/end with a letter or number."
),
},
)
@staticmethod
def _validate_ossfs_option(option: str) -> None:
"""
Validate OSSFS option to prevent command injection.
Options should not contain shell metacharacters or command separators.
"""
# Check for dangerous characters that could be used for command injection
dangerous_chars = [';', '&', '|', '`', '$', '(', ')', '<', '>', '\n', '\r']
for char in dangerous_chars:
if char in option:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": SandboxErrorCodes.INVALID_PARAMETER,
"message": (
f"Invalid OSSFS option: contains forbidden character '{char}'. "
"Options must not contain shell metacharacters."
),
},
)
@staticmethod
def _validate_mount_path(path: str) -> None:
"""
Validate mount path to prevent command injection in unmount operations.
Path must be absolute and not contain dangerous characters.
"""
if not path or not isinstance(path, str):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": SandboxErrorCodes.INVALID_PARAMETER,
"message": "Mount path cannot be empty.",
},
)
# Path must be absolute
if not path.startswith('/'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": SandboxErrorCodes.INVALID_PARAMETER,
"message": f"Mount path must be absolute: '{path}'",
},
)
# Check for dangerous characters that could be used for command injection
dangerous_chars = [';', '&', '|', '`', '$', '(', ')', '<', '>', '\n', '\r']
for char in dangerous_chars:
if char in path:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": SandboxErrorCodes.INVALID_PARAMETER,
"message": (
f"Invalid mount path: contains forbidden character '{char}'. "
"Paths must not contain shell metacharacters."
),
},
)
@staticmethod
def _validate_endpoint_url(endpoint_url: str) -> None:
"""
Validate endpoint URL to prevent command injection.
URL should not contain dangerous shell metacharacters.
"""
if not endpoint_url or not isinstance(endpoint_url, str):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": SandboxErrorCodes.INVALID_PARAMETER,
"message": "Endpoint URL cannot be empty.",
},
)
# Check for dangerous characters
dangerous_chars = [';', '&', '|', '`', '$', '(', ')', '<', '>', '\n', '\r', ' ']
for char in dangerous_chars:
if char in endpoint_url:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": SandboxErrorCodes.INVALID_PARAMETER,
"message": (
f"Invalid endpoint URL: contains forbidden character '{char}'. "
"URLs must not contain shell metacharacters or spaces."
),
},
)
@staticmethod
def _normalize_ossfs_option(raw_option: str) -> str:
option = str(raw_option).strip()
if not option:
return ""
return option
def _resolve_ossfs_paths(self, volume) -> tuple[str, str]:
"""
Resolve OSSFS base mount path and bind path.
For OSSFS, ``volume.subPath`` represents the bucket prefix.
The backend mount path and bind path are identical:
- path = ossfs_mount_root/<bucket>/<subPath?>
"""
mount_root = (self.app_config.storage.ossfs_mount_root or "").strip()
if not mount_root.startswith("/"):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"code": SandboxErrorCodes.INVALID_OSSFS_MOUNT_ROOT,
"message": (
"storage.ossfs_mount_root must be configured as an absolute path."
),
},
)
mount_root = posixpath.normpath(mount_root)
bucket_root = posixpath.normpath(posixpath.join(mount_root, volume.ossfs.bucket))
prefix = (volume.sub_path or "").lstrip("/")
backend_path = posixpath.normpath(posixpath.join(bucket_root, prefix))
bucket_prefix = bucket_root if bucket_root.endswith("/") else bucket_root + "/"
if backend_path != bucket_root and not backend_path.startswith(bucket_prefix):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": SandboxErrorCodes.INVALID_SUB_PATH,
"message": (
f"Volume '{volume.name}': resolved OSSFS prefix escapes bucket root."
),
},
)
return backend_path, backend_path
def _build_ossfs_v1_command(
self,
volume,
source: str,
backend_path: str,
endpoint_url: str,
passwd_file: str,
) -> list[str]:
# Validate inputs for security
self._validate_bucket_name(volume.ossfs.bucket)
self._validate_endpoint_url(endpoint_url)
self._validate_mount_path(backend_path)
cmd: list[str] = [
"ossfs",
source,
backend_path,
"-o",
f"url={endpoint_url}",
"-o",
f"passwd_file={passwd_file}",
]
if volume.ossfs.options:
for raw_opt in volume.ossfs.options:
opt = self._normalize_ossfs_option(raw_opt)
if opt:
# Validate each option for dangerous characters
self._validate_ossfs_option(opt)
cmd.extend(["-o", opt])
return cmd
def _build_ossfs_v2_config_lines(
self,
volume,
endpoint_url: str,
prefix: str,
) -> list[str]:
# Validate inputs for security
self._validate_bucket_name(volume.ossfs.bucket)
self._validate_endpoint_url(endpoint_url)
conf_lines: list[str] = [
f"--oss_endpoint={endpoint_url}",
f"--oss_bucket={volume.ossfs.bucket}",
f"--oss_access_key_id={volume.ossfs.access_key_id}",
f"--oss_access_key_secret={volume.ossfs.access_key_secret}",
]
if prefix:
normalized_prefix = prefix if prefix.endswith("/") else f"{prefix}/"
conf_lines.append(f"--oss_bucket_prefix={normalized_prefix}")
if volume.ossfs.options:
for raw_opt in volume.ossfs.options:
opt = self._normalize_ossfs_option(raw_opt)
if opt:
# Validate each option for dangerous characters
self._validate_ossfs_option(opt)
conf_lines.append(f"--{opt}")
return conf_lines
def _build_ossfs_v2_mount_command(self, backend_path: str, conf_file: str) -> list[str]:
# Validate backend path for security
self._validate_mount_path(backend_path)
return ["ossfs2", "mount", backend_path, "-c", conf_file]
@staticmethod
def _write_ossfs_private_config_file(file_path: str, content: str) -> None:
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
fd = os.open(file_path, flags, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
# OSSFS requires credentials in a local config/passwd file; the file
# is created mode 0600 with an unpredictable UUID name and removed
# immediately after the mount command completes.
# codeql[py/clear-text-storage-sensitive-data]
f.write(content)
@staticmethod
def _run_ossfs_mount_command(cmd: list[str], volume_name: str) -> None:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30,
check=False,
)
if result.returncode != 0:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"code": SandboxErrorCodes.OSSFS_MOUNT_FAILED,
"message": (
f"Volume '{volume_name}': failed to mount OSSFS backend. "
f"stderr={result.stderr.strip() or 'unknown error'}"
),
},
)
def _mount_ossfs_backend_path(self, volume, backend_path: str) -> None:
"""Mount OSS bucket/path to backend_path with version-specific OSSFS arguments."""
access_key_id = volume.ossfs.access_key_id
access_key_secret = volume.ossfs.access_key_secret
if not access_key_id or not access_key_secret:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": SandboxErrorCodes.INVALID_OSSFS_CREDENTIALS,
"message": (
"OSSFS inline credentials are required: "
"accessKeyId and accessKeySecret must be provided."
),
},
)
os.makedirs(backend_path, exist_ok=True)
bucket = volume.ossfs.bucket
prefix = (volume.sub_path or "").strip("/")
source = f"{bucket}:/{prefix}" if prefix else bucket
endpoint = volume.ossfs.endpoint
endpoint_url = normalize_external_endpoint_url(endpoint)
passwd_file: Optional[str] = None
conf_file: Optional[str] = None
version = volume.ossfs.version or "2.0"
try:
if version == "1.0":
passwd_file = os.path.join(
tempfile.gettempdir(),
f"opensandbox-ossfs-inline-{uuid4().hex}",
)
self._write_ossfs_private_config_file(
passwd_file,
f"{bucket}:{access_key_id}:{access_key_secret}",
)
cmd = self._build_ossfs_v1_command(
volume=volume,
source=source,
backend_path=backend_path,
endpoint_url=endpoint_url,
passwd_file=passwd_file,
)
elif version == "2.0":
conf_lines = self._build_ossfs_v2_config_lines(
volume=volume,
endpoint_url=endpoint_url,
prefix=prefix,
)
conf_file = os.path.join(
tempfile.gettempdir(),
f"opensandbox-ossfs2-{uuid4().hex}.conf",
)
self._write_ossfs_private_config_file(conf_file, "\n".join(conf_lines) + "\n")
cmd = self._build_ossfs_v2_mount_command(backend_path, conf_file)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": SandboxErrorCodes.INVALID_OSSFS_VERSION,
"message": (
f"Volume '{volume.name}': unsupported OSSFS version '{version}'."
),
},
)
self._run_ossfs_mount_command(cmd, volume.name)
except OSError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"code": SandboxErrorCodes.OSSFS_MOUNT_FAILED,
"message": (
f"Volume '{volume.name}': failed to execute ossfs command: {exc}"
),
},
) from exc
finally:
if passwd_file:
try:
os.remove(passwd_file)
except OSError:
pass
if conf_file:
try:
os.remove(conf_file)
except OSError:
pass
def _ensure_ossfs_mounted(self, volume_or_mount_key) -> str:
"""Ensure OSSFS backend path is mounted and return mount key."""
if isinstance(volume_or_mount_key, str):
mount_key = volume_or_mount_key
backend_path = volume_or_mount_key
volume = None
else:
volume = volume_or_mount_key
backend_path, _ = self._resolve_ossfs_paths(volume)
mount_key = backend_path
with self._ossfs_mount_lock:
current = self._ossfs_mount_ref_counts.get(mount_key, 0)
if current > 0:
self._ossfs_mount_ref_counts[mount_key] = current + 1
return mount_key
if not os.path.ismount(backend_path):
if volume is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"code": SandboxErrorCodes.OSSFS_MOUNT_FAILED,
"message": (
f"Failed to mount OSSFS path '{mount_key}': "
"missing volume context."
),
},
)
self._mount_ossfs_backend_path(volume, backend_path)
self._ossfs_mount_ref_counts[mount_key] = 1
return mount_key
def _release_ossfs_mount(self, mount_key: str) -> None:
"""Release one reference and unmount when ref count reaches zero."""
# Validate mount path before using in unmount commands
self._validate_mount_path(mount_key)
with self._ossfs_mount_lock:
current = self._ossfs_mount_ref_counts.get(mount_key, 0)
if current <= 0:
logger.warning(
"Skipping OSSFS unmount for untracked mount key '%s'.",
mount_key,
)
return
if current == 1:
self._ossfs_mount_ref_counts.pop(mount_key, None)
should_unmount = True
else:
self._ossfs_mount_ref_counts[mount_key] = current - 1
should_unmount = False
if not should_unmount or not os.path.ismount(mount_key):
return
errors: list[str] = []
for cmd in (["fusermount", "-u", mount_key], ["umount", mount_key]):
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=20,
check=False,
)
if result.returncode == 0:
return
errors.append(result.stderr.strip() or "unknown error")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"code": SandboxErrorCodes.OSSFS_UNMOUNT_FAILED,
"message": f"Failed to unmount OSSFS path '{mount_key}': {'; '.join(errors)}",
},
)
def _release_ossfs_mounts(self, mount_keys: list[str]) -> None:
for key in mount_keys:
try:
self._release_ossfs_mount(key)
except HTTPException as exc:
logger.warning("Failed to release OSSFS mount %s: %s", key, exc.detail)
def _prepare_ossfs_mounts(self, volumes: Optional[list]) -> list[str]:
if not volumes:
return []
key_to_volume: dict[str, Any] = {}
prepared_mount_keys: list[str] = []
for volume in volumes:
if volume.ossfs is not None:
mount_key, _ = self._resolve_ossfs_paths(volume)
if mount_key not in key_to_volume:
key_to_volume[mount_key] = volume
try:
for mount_key, volume in key_to_volume.items():
self._ensure_ossfs_mounted(volume)
prepared_mount_keys.append(mount_key)
return list(key_to_volume.keys())
except Exception:
# Roll back mounts already prepared in this batch.
self._release_ossfs_mounts(prepared_mount_keys)
raise
def _validate_ossfs_volume(self, volume) -> None:
"""
Docker-specific validation for OSSFS backend.
Ensures inline credentials and path semantics are valid.
"""
if os.name == "nt":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": SandboxErrorCodes.INVALID_PARAMETER,
"message": (
"OSSFS backend on Docker runtime requires a Linux host with FUSE support. "
"Running OpenSandbox Server on Windows is not supported for OSSFS mounts."
),
},
)
if not volume.ossfs.access_key_id or not volume.ossfs.access_key_secret:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": SandboxErrorCodes.INVALID_OSSFS_CREDENTIALS,
"message": (
"OSSFS inline credentials are required: "
"accessKeyId and accessKeySecret must be provided."
),
},
)
self._resolve_ossfs_paths(volume)