e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
529 lines
18 KiB
Python
529 lines
18 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Model folder recommendation and browsing services."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from fastapi import HTTPException
|
|
from loggers import get_logger
|
|
|
|
from hub.schemas.inventory import BrowseEntry, BrowseFoldersResponse
|
|
from hub.storage.scan_folders import (
|
|
contains_sensitive_path_component,
|
|
list_scan_folders,
|
|
)
|
|
from hub.utils.paths import (
|
|
exports_root,
|
|
hf_default_cache_dir,
|
|
legacy_hf_cache_dir,
|
|
lmstudio_model_dirs,
|
|
normalize_path,
|
|
outputs_root,
|
|
studio_root,
|
|
well_known_model_dirs,
|
|
)
|
|
from utils.paths.external_media import linux_run_media_mount_roots
|
|
from hub.services.models.common import _safe_is_dir
|
|
from hub.services.models.local_inventory import _resolve_hf_cache_dir
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
def get_recommended_folders_response() -> dict:
|
|
"""Return well-known model directories that exist on this machine."""
|
|
folders: list[str] = []
|
|
seen: set[str] = set()
|
|
|
|
def _add(p: Optional[Path]) -> None:
|
|
if p is None:
|
|
return
|
|
try:
|
|
resolved = str(p.resolve())
|
|
except OSError:
|
|
return
|
|
if resolved in seen:
|
|
return
|
|
if _safe_is_dir(resolved) and os.access(resolved, os.R_OK | os.X_OK):
|
|
seen.add(resolved)
|
|
folders.append(resolved)
|
|
|
|
try:
|
|
for p in lmstudio_model_dirs():
|
|
_add(p)
|
|
except Exception as e:
|
|
logger.warning("Failed to scan for LM Studio model directories: %s", e)
|
|
|
|
ollama_env = os.environ.get("OLLAMA_MODELS")
|
|
if ollama_env:
|
|
_add(Path(normalize_path(ollama_env)).expanduser())
|
|
for candidate in (
|
|
Path.home() / ".ollama" / "models",
|
|
Path("/usr/share/ollama/.ollama/models"),
|
|
Path("/var/lib/ollama/.ollama/models"),
|
|
):
|
|
_add(candidate)
|
|
|
|
return {"folders": folders}
|
|
|
|
|
|
# Ceiling on children to stat when guessing if a directory holds models.
|
|
_BROWSE_MODEL_HINT_PROBE = 64
|
|
# Hard cap on returned subdirectory entries so pointing at ``/usr/lib`` or
|
|
# ``/proc`` can't stat-storm the process or flood the client.
|
|
_BROWSE_ENTRY_CAP = 2000
|
|
|
|
|
|
def _count_model_files(directory: Path, cap: int = 200) -> int:
|
|
"""Count GGUF/safetensors files immediately inside *directory*, bounded by visited entries (not matches) so the hint never costs more than ``cap`` stats."""
|
|
n = 0
|
|
visited = 0
|
|
try:
|
|
for f in directory.iterdir():
|
|
visited += 1
|
|
if visited > cap:
|
|
break
|
|
try:
|
|
if f.is_file():
|
|
low = f.name.lower()
|
|
if low.endswith((".gguf", ".safetensors")):
|
|
n += 1
|
|
except OSError:
|
|
continue
|
|
except PermissionError as e:
|
|
logger.debug("browse-folders: permission denied counting %s: %s", directory, e)
|
|
return 0
|
|
except OSError as e:
|
|
logger.debug("browse-folders: OS error counting %s: %s", directory, e)
|
|
return 0
|
|
return n
|
|
|
|
|
|
def _has_direct_model_signal(directory: Path) -> bool:
|
|
"""True if an immediate child signals a model (GGUF/safetensors/config file or ``models--*`` HF-cache subdir); bounded by the hint probe."""
|
|
try:
|
|
it = directory.iterdir()
|
|
except OSError:
|
|
return False
|
|
try:
|
|
for i, child in enumerate(it):
|
|
if i >= _BROWSE_MODEL_HINT_PROBE:
|
|
break
|
|
try:
|
|
name = child.name
|
|
if child.is_file():
|
|
low = name.lower()
|
|
if low.endswith((".gguf", ".safetensors")):
|
|
return True
|
|
if low in ("config.json", "adapter_config.json"):
|
|
return True
|
|
elif child.is_dir() and name.startswith("models--"):
|
|
return True
|
|
except OSError:
|
|
continue
|
|
except OSError:
|
|
return False
|
|
return False
|
|
|
|
|
|
def _looks_like_model_dir(directory: Path) -> bool:
|
|
"""Bounded heuristic flagging dirs worth exploring (false negatives are fine; the scanner is authoritative). Three signals, cheapest first: a ``models--*`` name, a direct child signal, or a grandchild signal (LM Studio / Ollama ``publisher/model/weights.gguf`` layout)."""
|
|
if directory.name.startswith("models--"):
|
|
return True
|
|
if _has_direct_model_signal(directory):
|
|
return True
|
|
try:
|
|
it = directory.iterdir()
|
|
except OSError:
|
|
return False
|
|
try:
|
|
for i, child in enumerate(it):
|
|
if i >= _BROWSE_MODEL_HINT_PROBE:
|
|
break
|
|
try:
|
|
if not child.is_dir():
|
|
continue
|
|
except OSError:
|
|
continue
|
|
if child.name.startswith("models--"):
|
|
return True
|
|
if _has_direct_model_signal(child):
|
|
return True
|
|
except OSError:
|
|
return False
|
|
return False
|
|
|
|
|
|
def _build_browse_allowlist() -> list[Path]:
|
|
"""Root directories the browser may walk (also seeds the suggestion chips): HOME, resolved HF cache dirs, Studio outputs/exports/root, registered scan folders, and well-known local-LLM dirs. Each is added only if it resolves to a real directory so the sandbox has no dead boundary."""
|
|
from hub.storage.scan_folders import list_scan_folders
|
|
|
|
candidates: list[Path] = []
|
|
|
|
def _add(p: Optional[Path | str]) -> None:
|
|
if p is None:
|
|
return
|
|
try:
|
|
p = Path(normalize_path(str(p))).expanduser()
|
|
resolved = p.resolve()
|
|
except (OSError, RuntimeError, ValueError):
|
|
return
|
|
if _safe_is_dir(resolved):
|
|
candidates.append(resolved)
|
|
|
|
_add(Path.home())
|
|
for p in linux_run_media_mount_roots():
|
|
_add(p)
|
|
_add(_resolve_hf_cache_dir())
|
|
try:
|
|
_add(hf_default_cache_dir())
|
|
except Exception: # noqa: BLE001 -- best-effort
|
|
pass
|
|
try:
|
|
_add(legacy_hf_cache_dir())
|
|
except Exception: # noqa: BLE001 -- best-effort
|
|
pass
|
|
try:
|
|
_add(studio_root())
|
|
_add(outputs_root())
|
|
_add(exports_root())
|
|
except Exception as exc: # noqa: BLE001 -- best-effort
|
|
logger.debug("browse-folders: studio roots unavailable: %s", exc)
|
|
try:
|
|
for folder in list_scan_folders():
|
|
p = folder.get("path")
|
|
if p:
|
|
_add(p)
|
|
except Exception as exc: # noqa: BLE001 -- best-effort
|
|
logger.debug("browse-folders: could not load scan folders: %s", exc)
|
|
try:
|
|
for p in well_known_model_dirs():
|
|
_add(p)
|
|
except Exception as exc: # noqa: BLE001 -- best-effort
|
|
logger.debug("browse-folders: well-known dirs unavailable: %s", exc)
|
|
|
|
seen: set[str] = set()
|
|
deduped: list[Path] = []
|
|
for p in candidates:
|
|
key = os.path.normcase(os.path.realpath(str(p)))
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
deduped.append(p)
|
|
return deduped
|
|
|
|
|
|
def _is_path_inside_allowlist(target: Path, allowed_roots: list[Path]) -> bool:
|
|
"""True if *target* equals or descends from any allowed root; uses ``os.path.realpath`` so symlinks cannot escape the sandbox."""
|
|
try:
|
|
target_real = os.path.normcase(os.path.realpath(str(target)))
|
|
except OSError:
|
|
return False
|
|
for root in allowed_roots:
|
|
try:
|
|
root_real = os.path.normcase(os.path.realpath(str(root)))
|
|
except OSError:
|
|
continue
|
|
try:
|
|
if os.path.commonpath([target_real, root_real]) == root_real:
|
|
return True
|
|
except ValueError:
|
|
continue
|
|
if target_real == root_real:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _normalize_browse_request_path(path: Optional[str], *, relative_root: Path) -> str:
|
|
"""Normalize the browse request path lexically, without touching the FS."""
|
|
if path is None or not path.strip():
|
|
return os.path.normpath(str(Path.home()))
|
|
|
|
expanded = os.path.expanduser(normalize_path(path.strip()))
|
|
if not os.path.isabs(expanded):
|
|
expanded = os.path.join(str(relative_root), expanded)
|
|
return os.path.normpath(expanded)
|
|
|
|
|
|
def _browse_relative_parts(requested_path: str, root: Path) -> Optional[list[str]]:
|
|
if "\x00" in requested_path:
|
|
raise HTTPException(
|
|
status_code = 400,
|
|
detail = "Path cannot contain null bytes",
|
|
)
|
|
root_text = os.path.normcase(os.path.normpath(str(root)))
|
|
requested_text = os.path.normcase(os.path.normpath(requested_path))
|
|
try:
|
|
rel_text = os.path.relpath(requested_text, root_text)
|
|
except ValueError:
|
|
return None
|
|
|
|
if rel_text == ".":
|
|
return []
|
|
if rel_text == ".." or rel_text.startswith(f"..{os.sep}"):
|
|
return None
|
|
|
|
parts = [part for part in rel_text.split(os.sep) if part not in ("", ".")]
|
|
altsep = os.altsep
|
|
for part in parts:
|
|
if part == ".." or "\x00" in part or os.sep in part or (altsep and altsep in part):
|
|
return None
|
|
return parts
|
|
|
|
|
|
def _match_browse_child(current: Path, name: str) -> Optional[Path]:
|
|
"""Immediate child named ``name`` under ``current``, or None. ``name`` is pre-validated as a safe single component, so the join is O(1); case resolution follows OS filesystem semantics."""
|
|
child = current / name
|
|
try:
|
|
child.stat()
|
|
except (FileNotFoundError, NotADirectoryError):
|
|
return None
|
|
except PermissionError:
|
|
raise HTTPException(
|
|
status_code = 403,
|
|
detail = f"Permission denied reading {current}",
|
|
) from None
|
|
except OSError as exc:
|
|
raise HTTPException(
|
|
status_code = 500,
|
|
detail = f"Could not read {current}: {exc}",
|
|
) from exc
|
|
return child
|
|
|
|
|
|
def _resolve_browse_target(path: Optional[str], allowed_roots: list[Path]) -> Path:
|
|
"""Resolve a requested browse path by walking from trusted allowlist roots."""
|
|
requested_path = _normalize_browse_request_path(path, relative_root = Path.home())
|
|
resolved_roots: list[Path] = []
|
|
seen_roots: set[str] = set()
|
|
for root in sorted(allowed_roots, key = lambda p: len(str(p)), reverse = True):
|
|
try:
|
|
resolved = root.resolve()
|
|
except OSError:
|
|
continue
|
|
key = os.path.normcase(os.path.realpath(str(resolved)))
|
|
if key in seen_roots:
|
|
continue
|
|
seen_roots.add(key)
|
|
resolved_roots.append(resolved)
|
|
|
|
for root in resolved_roots:
|
|
parts = _browse_relative_parts(requested_path, root)
|
|
if parts is None:
|
|
continue
|
|
|
|
current = root
|
|
for part in parts:
|
|
child = _match_browse_child(current, part)
|
|
if child is None:
|
|
raise HTTPException(
|
|
status_code = 404,
|
|
detail = f"Path does not exist: {requested_path}",
|
|
)
|
|
try:
|
|
resolved_child = child.resolve()
|
|
except OSError as exc:
|
|
raise HTTPException(
|
|
status_code = 400,
|
|
detail = f"Invalid path: {exc}",
|
|
) from exc
|
|
if not _is_path_inside_allowlist(resolved_child, resolved_roots):
|
|
raise HTTPException(
|
|
status_code = 403,
|
|
detail = (
|
|
"Path is not in the browseable allowlist. Register it via "
|
|
"POST /api/hub/scan-folders first, or pick a directory "
|
|
"under your home folder."
|
|
),
|
|
)
|
|
# HOME is in the allowlist, so without this denylist (same one
|
|
# registration enforces) a user could browse into ~/.ssh, ~/.aws, etc.
|
|
if contains_sensitive_path_component(str(resolved_child)):
|
|
raise HTTPException(
|
|
status_code = 403,
|
|
detail = "Credential or configuration directories are not browseable.",
|
|
)
|
|
current = resolved_child
|
|
|
|
if contains_sensitive_path_component(str(current)):
|
|
raise HTTPException(
|
|
status_code = 403,
|
|
detail = "Credential or configuration directories are not browseable.",
|
|
)
|
|
if not current.is_dir():
|
|
raise HTTPException(
|
|
status_code = 400,
|
|
detail = f"Not a directory: {current}",
|
|
)
|
|
return current
|
|
|
|
raise HTTPException(
|
|
status_code = 403,
|
|
detail = (
|
|
"Path is not in the browseable allowlist. Register it via "
|
|
"POST /api/hub/scan-folders first, or pick a directory "
|
|
"under your home folder."
|
|
),
|
|
)
|
|
|
|
|
|
def browse_folders_response(
|
|
path: Optional[str] = None, show_hidden: bool = False
|
|
) -> BrowseFoldersResponse:
|
|
"""List immediate subdirectories of *path* for the Custom Folders picker.
|
|
|
|
Requests are bounded to the :func:`_build_browse_allowlist` roots; paths
|
|
outside it return 403 (symlinks resolved via realpath first, so traversal
|
|
can't escape). Sorting: model-bearing dirs first, then plain, then hidden.
|
|
"""
|
|
from hub.storage.scan_folders import list_scan_folders
|
|
|
|
# Build the allowlist once -- the sandbox check and suggestion chips share
|
|
# it so chips are always navigable.
|
|
allowed_roots = _build_browse_allowlist()
|
|
|
|
try:
|
|
target = _resolve_browse_target(path, allowed_roots)
|
|
except HTTPException:
|
|
requested_path = _normalize_browse_request_path(
|
|
path,
|
|
relative_root = Path.home(),
|
|
)
|
|
if path is not None and path.strip():
|
|
logger.warning(
|
|
"browse-folders: rejected path %r (normalized=%s)",
|
|
path,
|
|
requested_path,
|
|
)
|
|
raise
|
|
|
|
# Enumerate immediate subdirectories with a bounded cap so a stray
|
|
# query against ``/usr/lib`` or ``/proc`` can't stat-storm the process.
|
|
entries: list[BrowseEntry] = []
|
|
truncated = False
|
|
visited = 0
|
|
try:
|
|
it = target.iterdir()
|
|
except PermissionError:
|
|
raise HTTPException(
|
|
status_code = 403,
|
|
detail = f"Permission denied reading {target}",
|
|
)
|
|
except OSError as exc:
|
|
raise HTTPException(
|
|
status_code = 500,
|
|
detail = f"Could not read {target}: {exc}",
|
|
)
|
|
|
|
try:
|
|
for child in it:
|
|
# Bound by visited entries, not appended ones, so a directory full
|
|
# of files still caps work at ``_BROWSE_ENTRY_CAP`` stats.
|
|
visited += 1
|
|
if visited > _BROWSE_ENTRY_CAP:
|
|
truncated = True
|
|
break
|
|
try:
|
|
if not child.is_dir():
|
|
continue
|
|
except OSError:
|
|
continue
|
|
name = child.name
|
|
is_hidden = name.startswith(".")
|
|
if is_hidden and not show_hidden:
|
|
continue
|
|
# Don't surface credential/config dirs even with show_hidden:
|
|
# descending into them is refused and registration rejects them.
|
|
if contains_sensitive_path_component(name):
|
|
continue
|
|
entries.append(
|
|
BrowseEntry(
|
|
name = name,
|
|
has_models = _looks_like_model_dir(child),
|
|
hidden = is_hidden,
|
|
)
|
|
)
|
|
except PermissionError as exc:
|
|
logger.debug(
|
|
"browse-folders: permission denied during enumeration of %s: %s",
|
|
target,
|
|
exc,
|
|
)
|
|
except OSError as exc:
|
|
# Rare: iterdir succeeded but reading a specific entry failed.
|
|
logger.warning("browse-folders: partial enumeration of %s: %s", target, exc)
|
|
|
|
# Model-bearing dirs first, then plain, then hidden; case-insensitive
|
|
# alphabetical within each bucket.
|
|
def _sort_key(e: BrowseEntry) -> tuple[int, str]:
|
|
bucket = 0 if e.has_models else (2 if e.hidden else 1)
|
|
return (bucket, e.name.lower())
|
|
|
|
entries.sort(key = _sort_key)
|
|
|
|
# Parent is None at the FS root and when it would step outside the sandbox,
|
|
# so the up-row never 403s on click.
|
|
parent: Optional[str]
|
|
if target.parent == target or not _is_path_inside_allowlist(target.parent, allowed_roots):
|
|
parent = None
|
|
else:
|
|
parent = str(target.parent)
|
|
|
|
# Handy starting points for the quick-pick chips.
|
|
suggestions: list[str] = []
|
|
seen_sug: set[str] = set()
|
|
|
|
def _add_sug(p: Optional[Path | str]) -> None:
|
|
if p is None:
|
|
return
|
|
try:
|
|
p = Path(normalize_path(str(p))).expanduser()
|
|
resolved = str(p.resolve())
|
|
except (OSError, RuntimeError, ValueError):
|
|
return
|
|
if resolved in seen_sug:
|
|
return
|
|
if _safe_is_dir(resolved):
|
|
seen_sug.add(resolved)
|
|
suggestions.append(resolved)
|
|
|
|
# Home first as the safe fallback.
|
|
_add_sug(Path.home())
|
|
for p in linux_run_media_mount_roots():
|
|
_add_sug(p)
|
|
# The HF cache root in use (honors HF_HOME / HF_HUB_CACHE), then the default.
|
|
try:
|
|
_add_sug(_resolve_hf_cache_dir())
|
|
except Exception:
|
|
pass
|
|
try:
|
|
_add_sug(hf_default_cache_dir())
|
|
except Exception:
|
|
pass
|
|
# Already-registered scan folders (what the user has curated).
|
|
try:
|
|
for folder in list_scan_folders():
|
|
_add_sug(folder.get("path", ""))
|
|
except Exception as exc:
|
|
logger.debug("browse-folders: could not load scan folders: %s", exc)
|
|
# Well-known third-party dirs (LM Studio, Ollama, ~/models). Each helper
|
|
# only returns existing paths so we never show dead chips.
|
|
try:
|
|
for p in well_known_model_dirs():
|
|
_add_sug(p)
|
|
except Exception as exc:
|
|
logger.debug("browse-folders: could not load well-known dirs: %s", exc)
|
|
|
|
return BrowseFoldersResponse(
|
|
current = str(target),
|
|
parent = parent,
|
|
entries = entries,
|
|
suggestions = suggestions,
|
|
truncated = truncated,
|
|
model_files_here = _count_model_files(target),
|
|
)
|