"""Portable student CLI for registration and solution submission.""" from __future__ import annotations import argparse import getpass import os import queue import re import stat import subprocess import sys import tempfile import threading import unicodedata import uuid import zipfile from collections.abc import Callable, Sequence from datetime import datetime from pathlib import Path from typing import Any, cast from urllib.parse import urlsplit, urlunsplit import httpx import yaml DEFAULT_CONFIG = Path(__file__).resolve().with_name("cli.yaml") TOTAL_REQUEST_TIMEOUT_SECONDS = 120.0 SUCCESS_FIELDS = frozenset({"username", "password", "repo", "token"}) ERROR_FIELDS = frozenset({"code", "message", "request_id"}) REGISTER_ERROR_CODES = { 400: frozenset({"INVALID_REQUEST", "STUDENT_NOT_FOUND"}), 401: frozenset({"INVALID_REGISTRATION_TOKEN"}), 409: frozenset({"STUDENT_ALREADY_REGISTERED", "EMAIL_ALREADY_REGISTERED"}), 413: frozenset({"PAYLOAD_TOO_LARGE"}), 415: frozenset({"UNSUPPORTED_MEDIA_TYPE"}), 500: frozenset({"INTERNAL_ERROR"}), 502: frozenset({"GITEA_OPERATION_FAILED"}), 503: frozenset({"CONFIG_INVALID"}), } SUBMIT_ERROR_CODES = { 400: frozenset({"INVALID_REQUEST", "INVALID_ARCHIVE"}), 401: frozenset({"ACCESS_TOKEN_REQUIRED", "INVALID_ACCESS_TOKEN"}), 403: frozenset({"SUBMISSION_DEADLINE_EXPIRED"}), 404: frozenset({"ASSIGNMENT_NOT_FOUND"}), 409: frozenset({"SUBMISSION_IN_PROGRESS", "SOLUTION_UNCHANGED"}), 413: frozenset({"PAYLOAD_TOO_LARGE"}), 415: frozenset({"UNSUPPORTED_MEDIA_TYPE"}), 500: frozenset({"INTERNAL_ERROR"}), 502: frozenset({"GITEA_OPERATION_FAILED"}), 503: frozenset({"CONFIG_INVALID"}), } SUBMIT_SUCCESS_FIELDS = frozenset( {"submission_id", "accepted_at", "late_submission", "workflow_run"} ) ASSIGNMENT_ID_RE = re.compile(r"[a-z0-9][a-z0-9-]{0,63}\Z") _DRIVE_PATH_RE = re.compile(r"[A-Za-z]:") _UUID_RE = re.compile( r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-" r"[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\Z" ) _RFC3339_RE = re.compile( r"\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})\Z" ) _COPY_CHUNK_SIZE = 64 * 1024 MAX_PATH_SEGMENT_UTF8_BYTES = 255 MAX_SOLUTION_PATH_UTF8_BYTES = 1024 class CLIError(RuntimeError): """A safe command failure with its stable process exit code.""" def __init__(self, message: str, exit_code: int) -> None: super().__init__(message) self.exit_code = exit_code class BackendRejected(CLIError): """A successfully validated backend error envelope.""" def __init__(self, message: str, exit_code: int, backend_code: str) -> None: super().__init__(message, exit_code) self.backend_code = backend_code ClientFactory = Callable[[], httpx.Client] def normalize_server_url(value: str) -> str: """Normalize an absolute root HTTP(S) backend URL without exposing user info.""" if ( not value or any(char.isspace() or ord(char) < 0x20 or ord(char) == 0x7F for char in value) or "?" in value or "#" in value ): raise CLIError("server URL is invalid", 2) try: parsed = urlsplit(value) port = parsed.port except ValueError as exc: raise CLIError("server URL is invalid", 2) from exc if ( parsed.scheme.lower() not in {"http", "https"} or not parsed.hostname or parsed.username is not None or parsed.password is not None or parsed.path not in {"", "/"} or parsed.query or parsed.fragment ): raise CLIError("server URL is invalid", 2) host = parsed.hostname.lower() if ":" in host: host = f"[{host}]" netloc = host if port is None else f"{host}:{port}" normalized = urlunsplit((parsed.scheme.lower(), netloc, "", "", "")) try: httpx.URL(f"{normalized}/register") except httpx.InvalidURL as exc: raise CLIError("server URL is invalid", 2) from exc return normalized def _default_client() -> httpx.Client: return httpx.Client( timeout=httpx.Timeout(120.0, connect=10.0), follow_redirects=False, verify=True, ) def _post_registration_with_deadline( client_factory: ClientFactory, url: str, payload: dict[str, str], total_timeout_seconds: float, ) -> httpx.Response: outcomes: queue.SimpleQueue[httpx.Response | BaseException] = queue.SimpleQueue() def send() -> None: try: with client_factory() as client: outcomes.put(client.post(url, json=payload)) except BaseException as exc: outcomes.put(exc) worker = threading.Thread(target=send, name="distsys-cli-request", daemon=True) worker.start() worker.join(total_timeout_seconds) if worker.is_alive(): raise CLIError("network request failed", 4) outcome = outcomes.get() if isinstance(outcome, BaseException): raise outcome return outcome def _json_object(response: httpx.Response) -> dict[str, Any]: content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower() if content_type != "application/json": raise CLIError("backend returned an invalid response", 4) try: value = response.json() except ValueError as exc: raise CLIError("backend returned an invalid response", 4) from exc if not isinstance(value, dict): raise CLIError("backend returned an invalid response", 4) return value def _request_id(response: httpx.Response) -> str: request_id = response.headers.get("X-Request-ID") if not request_id: raise CLIError("backend returned an invalid response", 4) return str(request_id) def _validate_success(response: httpx.Response) -> tuple[dict[str, str], str]: request_id = _request_id(response) if response.headers.get("Cache-Control") != "no-store": raise CLIError("backend returned an invalid response", 4) body = _json_object(response) if set(body) != SUCCESS_FIELDS or any( not isinstance(body.get(field), str) or not body[field] for field in SUCCESS_FIELDS ): raise CLIError("backend returned an invalid response", 4) repository = str(body["repo"]) try: parsed_repository = urlsplit(repository) except ValueError as exc: raise CLIError("backend returned an invalid response", 4) from exc if ( any(char.isspace() for char in repository) or not parsed_repository.scheme or not parsed_repository.netloc ): raise CLIError("backend returned an invalid response", 4) return {field: str(body[field]) for field in SUCCESS_FIELDS}, request_id def _raise_backend_error( response: httpx.Response, allowed_errors: dict[int, frozenset[str]] = REGISTER_ERROR_CODES, ) -> None: request_id = _request_id(response) body = _json_object(response) if ( set(body) != ERROR_FIELDS or not all(isinstance(body.get(field), str) and body[field] for field in ERROR_FIELDS) or body["request_id"] != request_id ): raise CLIError("backend returned an invalid response", 4) allowed_codes = allowed_errors.get(response.status_code) if allowed_codes is None or body["code"] not in allowed_codes: raise CLIError("backend returned an invalid response", 4) status_class = response.status_code // 100 exit_code = 3 if status_class == 4 else 4 raise BackendRejected( f"{body['message']} (request ID: {request_id})", exit_code, str(body["code"]), ) class _StrictLoader(yaml.SafeLoader): """YAML loader that rejects duplicate mapping keys.""" def _strict_mapping(loader: _StrictLoader, node: yaml.MappingNode, deep: bool = False) -> Any: loader.flatten_mapping(node) result: dict[Any, Any] = {} for key_node, value_node in node.value: key = loader.construct_object(key_node, deep=deep) try: duplicate = key in result except TypeError as exc: raise yaml.constructor.ConstructorError( None, None, "mapping key is not hashable", key_node.start_mark ) from exc if duplicate: raise yaml.constructor.ConstructorError( None, None, "duplicate mapping key", key_node.start_mark ) result[key] = loader.construct_object(value_node, deep=deep) return result _StrictLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _strict_mapping) def _load_config(config_path: Path | None) -> tuple[str, str]: path = DEFAULT_CONFIG if config_path is None else config_path if config_path is not None and not path.is_absolute(): path = Path.cwd() / path try: documents = list(yaml.load_all(path.read_text(encoding="utf-8"), Loader=_StrictLoader)) except (OSError, UnicodeError, yaml.YAMLError) as exc: raise CLIError("configuration is invalid", 2) from exc if len(documents) != 1 or not isinstance(documents[0], dict): raise CLIError("configuration is invalid", 2) value = documents[0] if set(value) - {"server_url", "access_token"} or "server_url" not in value: raise CLIError("configuration is invalid", 2) server_url = value.get("server_url") access_token = value.get("access_token") if not isinstance(server_url, str) or not isinstance(access_token, str) or not access_token: raise CLIError("configuration is invalid or has no access token", 2) return normalize_server_url(server_url), access_token def _normalized_segment(value: str) -> str: if ( not value or value in {".", ".."} or "\\" in value or _DRIVE_PATH_RE.match(value) or any(unicodedata.category(character) == "Cc" for character in value) ): raise CLIError("solution contains an unsupported path", 2) normalized = unicodedata.normalize("NFC", value) if ( normalized in {"", ".", "..", ".git"} or len(normalized.encode("utf-8")) > MAX_PATH_SEGMENT_UTF8_BYTES ): raise CLIError("solution contains an unsupported path", 2) return normalized def _validate_solution_path(path: tuple[str, ...]) -> None: if len("/".join(path).encode("utf-8")) > MAX_SOLUTION_PATH_UTF8_BYTES: raise CLIError("solution contains an unsupported path", 2) def _collect_solution_entries( source: Path, ) -> list[tuple[Path, tuple[str, ...], bool, os.stat_result]]: entries: list[tuple[Path, tuple[str, ...], bool, os.stat_result]] = [] normalized_paths: dict[tuple[str, ...], bool] = {} def visit(directory: Path, relative: tuple[str, ...]) -> None: try: children = sorted(os.scandir(directory), key=lambda entry: entry.name) except OSError as exc: raise CLIError("solution directory cannot be read", 2) from exc for child in children: normalized = (*relative, _normalized_segment(child.name)) _validate_solution_path(normalized) try: metadata = child.stat(follow_symlinks=False) except OSError as exc: raise CLIError("solution entry cannot be read", 2) from exc mode = metadata.st_mode if stat.S_ISLNK(mode): raise CLIError("solution contains a symbolic link", 2) directory_entry = stat.S_ISDIR(mode) regular = stat.S_ISREG(mode) if not directory_entry and not regular: raise CLIError("solution contains a non-regular entry", 2) if regular and getattr(metadata, "st_nlink", 1) > 1: raise CLIError("solution contains a hard-linked file", 2) if normalized in normalized_paths: raise CLIError("solution contains duplicate normalized paths", 2) for length in range(1, len(normalized)): ancestor = normalized[:length] if ancestor in normalized_paths and not normalized_paths[ancestor]: raise CLIError("solution contains a file/directory conflict", 2) normalized_paths[normalized] = directory_entry entries.append((Path(child.path), normalized, directory_entry, metadata)) if directory_entry: visit(Path(child.path), normalized) visit(source, ()) if not any(not directory for _, _, directory, _ in entries): raise CLIError("solution directory contains no regular files", 2) return entries def _zip_info(name: str, mode: int, *, directory: bool) -> zipfile.ZipInfo: info = zipfile.ZipInfo(name + ("/" if directory else "")) info.create_system = 3 file_type = stat.S_IFDIR if directory else stat.S_IFREG info.external_attr = (file_type | mode) << 16 info.compress_type = zipfile.ZIP_DEFLATED return info def _source_is_executable(mode: int, platform_name: str = os.name) -> bool: """Return whether this platform exposes any executable bit for a source file.""" return platform_name == "posix" and bool(mode & 0o111) def _resolve_solution_directory(solution_dir: Path) -> Path: try: source = solution_dir.resolve(strict=True) except OSError as exc: raise CLIError("solution directory does not exist", 2) from exc if not source.is_dir(): raise CLIError("solution path is not a directory", 2) return source def build_solution_archive(solution_dir: Path, destination: Path) -> None: """Build one normalized, link-free archive below a synthetic solution root.""" source = _resolve_solution_directory(solution_dir) entries = _collect_solution_entries(source) try: with zipfile.ZipFile(destination, "w", allowZip64=True) as archive: archive.writestr(_zip_info("solution", 0o755, directory=True), b"") for path, relative, directory, selected_metadata in entries: member_name = "solution/" + "/".join(relative) if directory: archive.writestr(_zip_info(member_name, 0o755, directory=True), b"") continue executable = _source_is_executable(selected_metadata.st_mode) permissions = 0o755 if executable else 0o644 flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0) descriptor = os.open(path, flags) try: current = os.fstat(descriptor) if getattr(current, "st_nlink", 1) > 1: raise CLIError("solution contains a hard-linked file", 2) identity_changed = os.name == "posix" and ( current.st_dev != selected_metadata.st_dev or current.st_ino != selected_metadata.st_ino ) metadata_changed = ( current.st_size != selected_metadata.st_size or current.st_mtime_ns != selected_metadata.st_mtime_ns ) if not stat.S_ISREG(current.st_mode) or identity_changed or metadata_changed: raise CLIError("solution changed while it was archived", 2) with os.fdopen(descriptor, "rb", closefd=True) as source_file: descriptor = -1 info = _zip_info(member_name, permissions, directory=False) with archive.open(info, "w") as output: while chunk := source_file.read(_COPY_CHUNK_SIZE): output.write(chunk) final = os.fstat(source_file.fileno()) if ( final.st_size != current.st_size or final.st_mtime_ns != current.st_mtime_ns ): raise CLIError("solution changed while it was archived", 2) finally: if descriptor >= 0: os.close(descriptor) except CLIError: raise except (OSError, ValueError, zipfile.BadZipFile) as exc: raise CLIError("solution archive could not be created", 1) from exc def _post_archive_with_deadline( client_factory: ClientFactory, url: str, archive_path: Path, access_token: str, total_timeout_seconds: float, ) -> httpx.Response: outcomes: queue.SimpleQueue[httpx.Response | BaseException] = queue.SimpleQueue() # Windows does not permit unlinking a file held open by the request worker. # Detach the request body from the temporary path before enforcing the outer # wall-clock deadline so timeout cleanup remains immediate. windows_content = archive_path.read_bytes() if os.name == "nt" else None headers = { "Authorization": f"token {access_token}", "Content-Type": "application/zip", } def send() -> None: try: with client_factory() as client: if windows_content is not None: outcomes.put(client.post(url, content=windows_content, headers=headers)) else: with archive_path.open("rb") as content: outcomes.put(client.post(url, content=content, headers=headers)) except BaseException as exc: outcomes.put(exc) finally: _remove_temporary_archive(archive_path) worker = threading.Thread(target=send, name="distsys-cli-request", daemon=True) worker.start() worker.join(total_timeout_seconds) if worker.is_alive(): raise CLIError("network request failed", 4) outcome = outcomes.get() if isinstance(outcome, BaseException): raise outcome return outcome def _remove_temporary_archive(path: Path) -> None: try: path.unlink(missing_ok=True) except OSError: pass def _validate_submit_success(response: httpx.Response) -> dict[str, str | bool]: _request_id(response) body = _json_object(response) if set(body) != SUBMIT_SUCCESS_FIELDS: raise CLIError("backend returned an invalid response", 4) submission_id = body.get("submission_id") accepted_at = body.get("accepted_at") late = body.get("late_submission") workflow_run = body.get("workflow_run") if not isinstance(submission_id, str) or not submission_id: raise CLIError("backend returned an invalid response", 4) if not isinstance(accepted_at, str) or not accepted_at: raise CLIError("backend returned an invalid response", 4) if not isinstance(workflow_run, str) or not workflow_run: raise CLIError("backend returned an invalid response", 4) if not isinstance(late, bool): raise CLIError("backend returned an invalid response", 4) if _UUID_RE.fullmatch(submission_id) is None or _RFC3339_RE.fullmatch(accepted_at) is None: raise CLIError("backend returned an invalid response", 4) try: uuid.UUID(submission_id) normalized_time = accepted_at[:-1] + "+00:00" if accepted_at[-1] in "Zz" else accepted_at parsed_time = datetime.fromisoformat(normalized_time) parsed_url = urlsplit(workflow_run) workflow_port = parsed_url.port parsed_http_url = httpx.URL(workflow_run) except (UnicodeError, ValueError, TypeError, httpx.InvalidURL) as exc: raise CLIError("backend returned an invalid response", 4) from exc if ( parsed_time.tzinfo is None or accepted_at.endswith("-00:00") or parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc or parsed_url.hostname is None or parsed_url.username is not None or parsed_url.password is not None or workflow_port == 0 or not parsed_http_url.host or any( character.isspace() or ord(character) < 0x20 or ord(character) == 0x7F for character in workflow_run ) ): raise CLIError("backend returned an invalid response", 4) return { "submission_id": submission_id, "accepted_at": accepted_at, "late_submission": late, "workflow_run": workflow_run, } def _format_accepted_at(accepted_at: str) -> str: normalized_time = accepted_at[:-1] + "+00:00" if accepted_at[-1] in "Zz" else accepted_at return datetime.fromisoformat(normalized_time).strftime("%Y-%m-%d %H:%M:%S") def submit( assignment_id: str, solution_dir: Path, config_path: Path | None, *, client_factory: ClientFactory = _default_client, total_timeout_seconds: float = TOTAL_REQUEST_TIMEOUT_SECONDS, ) -> None: if ASSIGNMENT_ID_RE.fullmatch(assignment_id) is None: raise CLIError("assignment ID is invalid", 2) server_url, access_token = _load_config(config_path) resolved_solution = _resolve_solution_directory(solution_dir) descriptor, archive_name = tempfile.mkstemp(prefix="distsys-solution-", suffix=".zip") os.close(descriptor) archive_path = Path(archive_name) try: try: archive_path.resolve().relative_to(resolved_solution) except ValueError: pass else: raise CLIError("temporary archive location overlaps the solution directory", 2) build_solution_archive(resolved_solution, archive_path) try: response = _post_archive_with_deadline( client_factory, f"{server_url}/submit/{assignment_id}", archive_path, access_token, total_timeout_seconds, ) except (httpx.HTTPError, httpx.InvalidURL, OSError) as exc: raise CLIError("network request failed", 4) from exc if response.status_code != 201: try: _raise_backend_error(response, SUBMIT_ERROR_CODES) except BackendRejected as exc: if exc.backend_code == "SOLUTION_UNCHANGED": raise CLIError(f"{exc}; no grading run was created", 3) from None raise result = _validate_submit_success(response) finally: _remove_temporary_archive(archive_path) print(f"Submission ID: {result['submission_id']}") print(f"Accepted at: {_format_accepted_at(cast(str, result['accepted_at']))}") print(f"Late submission: {str(result['late_submission']).lower()}") print(f"Workflow run: {result['workflow_run']}") def _restrict_windows(path: Path) -> bool: creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) try: identity = subprocess.run( ["whoami"], check=False, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, creationflags=creation_flags, ) principal = identity.stdout.strip() if identity.returncode != 0 or not principal: return False completed = subprocess.run( [ "icacls", str(path), "/inheritance:r", "/grant:r", f"{principal}:(F)", ], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=creation_flags, ) except OSError: return False return completed.returncode == 0 def _write_config(destination: Path, server_url: str, access_token: str) -> bool: """Synchronize a private sibling temporary file, then atomically replace.""" payload = yaml.safe_dump( {"server_url": server_url, "access_token": access_token}, allow_unicode=True, sort_keys=False, ).encode("utf-8") descriptor = -1 temporary: Path | None = None permission_warning = False try: descriptor, temporary_name = tempfile.mkstemp( prefix=f".{destination.name}.", dir=destination.parent ) temporary = Path(temporary_name) with os.fdopen(descriptor, "wb", closefd=True) as stream: descriptor = -1 stream.write(payload) stream.flush() os.fsync(stream.fileno()) if os.name == "posix": temporary.chmod(stat.S_IRUSR | stat.S_IWUSR) if os.name == "nt" and not _restrict_windows(temporary): permission_warning = True os.replace(temporary, destination) temporary = None finally: if descriptor >= 0: os.close(descriptor) if temporary is not None: try: temporary.unlink() except OSError: pass return permission_warning def _resolve_destination(value: Path | None) -> Path: destination = DEFAULT_CONFIG if value is None else value if value is not None and not destination.is_absolute(): destination = Path.cwd() / destination try: destination = destination.resolve(strict=False) destination.parent.mkdir(parents=True, exist_ok=True) except OSError as exc: raise CLIError("configuration destination is not usable", 2) from exc if destination.exists() and destination.is_dir(): raise CLIError("configuration destination is not a file", 2) return destination def _masked_email(email: str) -> str: local, separator, domain = email.rpartition("@") if not separator or not local or not domain: return "***" return f"{local[0]}***@{domain}" def register( server_url: str, config_path: Path | None, *, input_fn: Callable[[str], str] = input, password_fn: Callable[[str], str] = getpass.getpass, client_factory: ClientFactory = _default_client, total_timeout_seconds: float = TOTAL_REQUEST_TIMEOUT_SECONDS, ) -> None: normalized_url = normalize_server_url(server_url) destination = _resolve_destination(config_path) registration_token = password_fn("Registration token: ") name = input_fn("Full name: ") email = input_fn("Email: ") try: response = _post_registration_with_deadline( client_factory, f"{normalized_url}/register", {"name": name, "email": email, "reg_token": registration_token}, total_timeout_seconds, ) except (httpx.HTTPError, httpx.InvalidURL) as exc: raise CLIError("network request failed", 4) from exc if response.status_code != 201: _raise_backend_error(response) result, request_id = _validate_success(response) try: permission_warning = _write_config(destination, normalized_url, result["token"]) except OSError as exc: raise CLIError( "Registration succeeded, but credentials could not be stored. " f"Contact course staff with request ID: {request_id}", 1, ) from exc if permission_warning: print( "warning: could not restrict configuration permissions to the current user", file=sys.stderr, ) print(f"Username: {result['username']}") print(f"Temporary password: {result['password']}") print(f"Repository: {result['repo']}") print(f"Registration email: Check {_masked_email(email)} for your Gitea account details.") print("If it does not arrive within 5 minutes, check spam and contact course staff.") print("Action required: Sign in to Gitea and change the temporary password before submitting.") def parse_args(arguments: Sequence[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--config", type=Path, default=None) subparsers = parser.add_subparsers(dest="command", required=True) register_parser = subparsers.add_parser("register", help="create a student account") register_parser.add_argument("--config", type=Path, default=argparse.SUPPRESS) register_parser.add_argument("server_url") submit_parser = subparsers.add_parser("submit", help="submit a solution for grading") submit_parser.add_argument("--config", type=Path, default=argparse.SUPPRESS) submit_parser.add_argument("--assignment-id") submit_parser.add_argument("--solution-dir", type=Path) return parser.parse_args(arguments) def _resolve_submit_options( assignment_id: str | None, solution_dir: Path | None ) -> tuple[str, Path]: if assignment_id is None and solution_dir is None: working_directory = Path.cwd() default_solution = working_directory / "solution" if not default_solution.is_dir(): raise CLIError("submit without options requires a solution subdirectory", 2) return working_directory.name, default_solution if assignment_id is None or solution_dir is None: raise CLIError("--assignment-id and --solution-dir must be provided together", 2) return assignment_id, solution_dir def main(arguments: Sequence[str] | None = None) -> int: args = parse_args(arguments) try: if args.command == "register": register(args.server_url, args.config) elif args.command == "submit": assignment_id, solution_dir = _resolve_submit_options( args.assignment_id, args.solution_dir ) submit(assignment_id, solution_dir, args.config) except CLIError as exc: print(str(exc), file=sys.stderr) return exc.exit_code return 0 if __name__ == "__main__": raise SystemExit(main())