* Studio: reject whitespace-only passwords * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: reject any whitespace in passwords * Studio: surface whitespace error in setup form, isolate auth test import --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han <danielhanchen@gmail.com>
244 lines
9 KiB
Python
244 lines
9 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
|
|
|
|
"""Masked terminal password prompt for the first-exposure password change.
|
|
|
|
Mirror of ``studio/backend/auth/terminal_prompt.py`` -- keep the two in sync.
|
|
The CLI parent cannot import the backend package outside the studio venv, so the
|
|
reader is duplicated here (like the auth mirroring in ``commands/studio.py``).
|
|
|
|
Input echoes one ``*`` per character (unlike ``getpass``). All output goes to
|
|
stderr so redirected stdout stays clean.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
from typing import Callable, TextIO
|
|
|
|
# Keep in sync with studio/backend/models/auth.py ChangePasswordRequest
|
|
# (new_password min_length) and studio/backend/auth/storage.py.
|
|
MIN_PASSWORD_LENGTH = 8
|
|
|
|
# Env var that supplies the initial admin password non-interactively (mirror in
|
|
# studio/backend/auth/terminal_prompt.py). Keep the name in sync.
|
|
SUPPLIED_PASSWORD_ENV = "UNSLOTH_STUDIO_PASSWORD"
|
|
|
|
_BACKSPACE_CHARS = ("\x7f", "\x08")
|
|
_SUBMIT_CHARS = ("\r", "\n")
|
|
|
|
|
|
class _RestoreTtyOnSignals:
|
|
"""Restore terminal attrs if SIGTERM/SIGHUP kills the prompt mid-read.
|
|
|
|
A finally block can't run when a signal terminates the process, leaving the
|
|
shared terminal in cbreak/no-echo. Best-effort: no-op off the main thread or
|
|
where the signals are absent.
|
|
"""
|
|
|
|
def __init__(self, fd: int, old_attrs) -> None:
|
|
self._fd = fd
|
|
self._old_attrs = old_attrs
|
|
self._previous: list = []
|
|
|
|
def __enter__(self) -> "_RestoreTtyOnSignals":
|
|
import signal
|
|
import termios
|
|
|
|
def _restore_and_reraise(signum, frame):
|
|
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old_attrs)
|
|
signal.signal(signum, signal.SIG_DFL)
|
|
signal.raise_signal(signum)
|
|
|
|
for name in ("SIGTERM", "SIGHUP"):
|
|
sig = getattr(signal, name, None)
|
|
if sig is None:
|
|
continue
|
|
try:
|
|
self._previous.append((sig, signal.signal(sig, _restore_and_reraise)))
|
|
except (ValueError, OSError): # non-main thread / unsupported
|
|
pass
|
|
return self
|
|
|
|
def __exit__(self, *exc) -> None:
|
|
import signal
|
|
for sig, previous in self._previous:
|
|
try:
|
|
signal.signal(sig, previous)
|
|
except (ValueError, OSError):
|
|
pass
|
|
|
|
|
|
def _read_masked_posix(prompt: str, out: TextIO) -> str:
|
|
import codecs
|
|
import termios
|
|
import tty
|
|
|
|
fd = sys.stdin.fileno()
|
|
old_attrs = termios.tcgetattr(fd)
|
|
out.write(prompt)
|
|
out.flush()
|
|
chars: list[str] = []
|
|
try:
|
|
with _RestoreTtyOnSignals(fd, old_attrs):
|
|
# cbreak + ISIG off (mirrors terminal_prompt.py): with ISIG on,
|
|
# Ctrl-Z would suspend mid-read and leave the shell no-echo before
|
|
# the finally restores it. Ctrl-C/Ctrl-Z arrive as \x03/\x1a here.
|
|
tty.setcbreak(fd)
|
|
new_attrs = termios.tcgetattr(fd)
|
|
new_attrs[3] &= ~termios.ISIG
|
|
termios.tcsetattr(fd, termios.TCSADRAIN, new_attrs)
|
|
# Decode byte-at-a-time with errors="replace" (mirrors
|
|
# terminal_prompt.py): text-mode read(1) can raise UnicodeDecodeError
|
|
# on a pasted non-UTF-8 password or yield a lone surrogate that later
|
|
# crashes pbkdf2. os.read + incremental decoder maps bad bytes to
|
|
# U+FFFD and continues.
|
|
decoder = codecs.getincrementaldecoder(sys.stdin.encoding or "utf-8")("replace")
|
|
submitted = False
|
|
while not submitted:
|
|
raw = os.read(fd, 1)
|
|
if not raw: # stream ended mid-line: abort, don't submit
|
|
raise EOFError
|
|
# One byte can complete >1 char, so iterate over the decoder's output.
|
|
for ch in decoder.decode(raw):
|
|
if ch in _SUBMIT_CHARS:
|
|
submitted = True
|
|
break
|
|
if ch == "\x03": # Ctrl-C (ISIG off: surfaces as a char)
|
|
raise KeyboardInterrupt
|
|
if ch in ("\x04", "\x1a"): # Ctrl-D / Ctrl-Z
|
|
if not chars:
|
|
raise EOFError
|
|
continue
|
|
if ch in _BACKSPACE_CHARS:
|
|
if chars:
|
|
chars.pop()
|
|
out.write("\b \b")
|
|
out.flush()
|
|
continue
|
|
if ch < " ": # other control characters
|
|
continue
|
|
chars.append(ch)
|
|
out.write("*")
|
|
out.flush()
|
|
finally:
|
|
termios.tcsetattr(fd, termios.TCSADRAIN, old_attrs)
|
|
out.write("\n")
|
|
out.flush()
|
|
return "".join(chars)
|
|
|
|
|
|
def _read_masked_windows(prompt: str, out: TextIO) -> str:
|
|
import msvcrt
|
|
|
|
out.write(prompt)
|
|
out.flush()
|
|
chars: list[str] = []
|
|
try:
|
|
while True:
|
|
ch = msvcrt.getwch()
|
|
if ch in _SUBMIT_CHARS:
|
|
break
|
|
if ch == "\x03": # Ctrl-C: getwch swallows the signal, re-raise
|
|
raise KeyboardInterrupt
|
|
if ch in ("\x04", "\x1a"): # Ctrl-D / Ctrl-Z
|
|
if not chars:
|
|
raise EOFError
|
|
continue
|
|
if ch in ("\x00", "\xe0"): # function/arrow key: swallow the code
|
|
msvcrt.getwch()
|
|
continue
|
|
if ch in _BACKSPACE_CHARS:
|
|
if chars:
|
|
chars.pop()
|
|
out.write("\b \b")
|
|
out.flush()
|
|
continue
|
|
if ch < " ":
|
|
continue
|
|
chars.append(ch)
|
|
out.write("*")
|
|
out.flush()
|
|
finally:
|
|
out.write("\n")
|
|
out.flush()
|
|
return "".join(chars)
|
|
|
|
|
|
def read_masked(prompt: str, out: TextIO | None = None) -> str:
|
|
"""Read one line with ``*`` echo. Raises KeyboardInterrupt on Ctrl-C and
|
|
EOFError on Ctrl-D/Ctrl-Z at an empty prompt."""
|
|
if out is None:
|
|
out = sys.stderr
|
|
if os.name == "nt":
|
|
return _read_masked_windows(prompt, out)
|
|
return _read_masked_posix(prompt, out)
|
|
|
|
|
|
def prompt_new_password(verify_current: Callable[[str], bool], out: TextIO | None = None) -> str:
|
|
"""Prompt for a new admin password until a valid, confirmed one is given.
|
|
|
|
``verify_current`` returns True when the candidate equals the current stored
|
|
password; such candidates are rejected. KeyboardInterrupt/EOFError propagate
|
|
so the caller can abort the launch.
|
|
"""
|
|
if out is None:
|
|
out = sys.stderr
|
|
while True:
|
|
password = read_masked("New password: ", out)
|
|
if len(password) < MIN_PASSWORD_LENGTH:
|
|
out.write(f"Password must be at least {MIN_PASSWORD_LENGTH} characters. Try again.\n")
|
|
out.flush()
|
|
continue
|
|
if any(ch.isspace() for ch in password):
|
|
out.write("Password cannot contain spaces. Try again.\n")
|
|
out.flush()
|
|
continue
|
|
if verify_current(password):
|
|
out.write("New password must differ from the current password. Try again.\n")
|
|
out.flush()
|
|
continue
|
|
confirmation = read_masked("Confirm new password: ", out)
|
|
if confirmation != password:
|
|
out.write("Passwords do not match. Try again.\n")
|
|
out.flush()
|
|
continue
|
|
return password
|
|
|
|
|
|
def resolve_supplied_password(cli_value: "str | None", out: TextIO | None = None) -> "str | None":
|
|
"""Resolve a non-interactive initial admin password, or None if unset.
|
|
|
|
Precedence: an explicit ``--password`` (literal ``-`` reads a line from
|
|
stdin), then the ``UNSLOTH_STUDIO_PASSWORD`` env var; empty/omitted means off.
|
|
A literal argv value is visible in the process list, so a note points at the
|
|
env var or stdin instead. Mirror of the backend helper -- keep the two in sync.
|
|
"""
|
|
if out is None:
|
|
out = sys.stderr
|
|
if cli_value == "-":
|
|
line = sys.stdin.readline()
|
|
if not line:
|
|
return None
|
|
return line.rstrip("\r\n") or None
|
|
if cli_value:
|
|
out.write(
|
|
"Note: --password is visible in the process list and shell history; "
|
|
f"prefer {SUPPLIED_PASSWORD_ENV} or --password - (stdin).\n"
|
|
)
|
|
out.flush()
|
|
return cli_value
|
|
return os.environ.get(SUPPLIED_PASSWORD_ENV) or None
|
|
|
|
|
|
def validate_new_password(candidate: str, verify_current: Callable[[str], bool]) -> "str | None":
|
|
"""Error message if ``candidate`` is unacceptable (too short or equal to the
|
|
current password), else None. Same policy as the interactive loop."""
|
|
if len(candidate) < MIN_PASSWORD_LENGTH:
|
|
return f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
|
|
if any(ch.isspace() for ch in candidate):
|
|
return "Password cannot contain spaces."
|
|
if verify_current(candidate):
|
|
return "New password must differ from the current password."
|
|
return None
|