mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-24 16:23:51 +00:00
* Studio: sync linked folders into RAG * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix linked folder lifecycle edge cases * Fix linked folder sync review issues * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix linked folder sync lifecycle * Fix linked folder sync review findings * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Handle linked folder availability and KB cleanup * Harden linked folder filesystem reconciliation * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Retire project RAG scope before deletion * Skip project RAG cleanup when unavailable * Stabilize linked folder reconciliation * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Guard linked folder deletion races * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Guard project RAG uploads during deletion * Fix linked folder scope deletion recovery * Fix deletion transaction and native lease calls * Avoid Studio write locks during folder sync * Harden linked folder startup and scans * Preserve linked folder scans with weak identities * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Make linked folder reconciliation crash-safe * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Preserve linked folder lifecycle intent * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Make linked folder retirement cleanup durable * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Close linked folder lifecycle races * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Close RAG visibility and lease gaps * Coordinate durable RAG workers * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Refine linked folder lifecycle and reconciliation Preserve signed folder identities through registration and revalidate them before persistence. Use durable scope tombstones, owner-first project deletion, file-first cleanup, and lease-based job recovery. Run linked-folder ingestion within the folder worker and retain prior mappings when reconciliation is incomplete. Remove compatibility paths for unreleased linked-folder schemas and reuse the shared scoped single-flight request helper. Consolidate regression coverage while preserving lifecycle, concurrency, and cleanup scenarios. * Align Windows path identities and preserve leased RAG jobs * Encode 128-bit file identities for linked-folder mappings in PR #8014 CPython 3.12+ fills st_ino from FILE_ID_INFO, so on ReFS and Dev Drive volumes a file id is 128-bit and does not fit SQLite's signed 64-bit INTEGER. The root identity was already hex-encoded through _store_identity; the per-file mappings still wrote st_dev/st_ino raw, so _install_mapping raised OverflowError for every file and nothing on those volumes could ever be indexed. Route the per-file identity through the same encoding and compare change detection on the encoded pair, so existing integer rows keep matching. * Reap linked-folder document orphans during periodic scheduling _recover_startup_state() runs once, before the worker loop. When a second backend crashes between a completed ingestion and _install_mapping(), the surviving process reclaims the expired folder job and reindexes the file, but its own startup pass is long past, so the earlier document, its chunks and its snapshot stay unreferenced forever and keep answering searches alongside the replacement. Extract the orphan reap and run it from _enqueue_periodic() as well. The live ingestion and folder-sync lease guards are unchanged, so work another backend still owns is left alone until its lease expires. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Remove replaced linked-folder snapshots only after the mapping commits _install_mapping() unlinked the previous snapshot inside the transaction. A commit failure, for example SQLITE_BUSY under the multi-process contention this feature already handles, rolls the mapping and the document deletion back but cannot restore the file, leaving the prior document searchable while preview and download fail on a stored_path that no longer exists. The delete paths keep file-first ordering on purpose, since there the surviving row is the retry queue. Here the surviving row is live data, so the removal now runs after the commit through _remove_snapshot(), which logs rather than failing an installed mapping. * Release skipped folder-sync claims and keep the worker alive on queue errors _next_job() claims and activates a job before dispatching it, and reconcile_folder() then re-claims. When an unlink lands in between it fails the job, the second claim returns None, and the early return skipped the finally that releases the lease. The heartbeat renewed that claim every 5 seconds for the process lifetime, so _delete_retired_folder() never saw it expire and delete_folder() spun on its 50 ms wait until restart. Separately, _next_job() ran outside any retry block, so a writer-lock timeout unwound the worker through the outer finally and left nothing to relaunch it, stopping every linked-folder scan for the process lifetime. It now backs off and retries like the initialization and periodic paths already do. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Defer deleted-file snapshot removal and release exhausted folder polling _delete_mapping() unlinked the snapshot inside its transaction. A failed commit rolls the mapping, document and chunks back but cannot restore the file, so the document stays searchable while preview and download fail. Unlike the retired folder queue, which _reconcile_retired_folder_deletions() retries every cycle, nothing re-drives this path on a folder with auto_sync off, so the broken state can persist until someone syncs by hand. Removal now runs after the commit, as _install_mapping() already does. The folder job poller also only released its controller from the catch block, so exhausting all 600 attempts left the id in controllers.current; the refresh then treated the job as tracked and never restarted polling, freezing progress until unmount. * fix linked-folder deletion, sync coalescing and buffered progress streams Six defects found while exercising the linked-folder pipeline end to end. Deletions no longer depend on a clean ingest. `_reconcile_folder` gated its delete pass on `failed == 0`, so a single permanently unparseable file kept every removal from ever applying: a document the user deleted from the folder stayed indexed and kept coming back in retrieval, with no way out, since managed documents cannot be deleted by hand. Each vanished path now gets one grace pass, recorded in `linked_folders.withheld_paths`, which preserves the guarantee that a rename or replace keeps its prior document while its replacement fails to index. A sync requested during a running sync is no longer dropped. `_request_sync` folded it into the running job and returned that job's id, but that job had already fixed its file list, so the change was never indexed and the user was shown a completed sync. `rebuild_requested` becomes `successor_kind`, one column covering both kinds, and `_fold_into_active_job` queues a follow-up whenever the running job cannot cover the request. Folder-sync progress survives a reverse proxy. A Cloudflare tunnel buffers the whole event stream until the response ends, which no origin header, padding or keepalive prevents, so the UI sat at zero for the entire sync. `readSseJsonEvents` abandons a stream that goes silent for 12s and the caller reconciles by polling, and `job_events` now emits a keepalive so only a genuinely buffered stream trips it. Also fixed: the job error names the files that could not be indexed instead of only counting them; `Content-Disposition` carries the base name rather than the folder-relative path; and `rag_home` places its studio home outside the macOS denylist, where `/private/var/folders` made 48 of the 67 linked-folder tests fail. Refactoring: `streamJobEvents` and `streamFolderSyncJobEvents` share one reader, `sse-framing.ts` moves to `src/lib` so its frame splitter is the only one, and `ensure_linked_folder_columns` upgrades both the vector and metadata connections. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * do not retire a project rag scope that was recreated during delete The row delete commits before the workspace work, so another client can create a project with the same id in that window. Retirement writes a permanent tombstone, which left the new project unable to link folders or upload documents at all. Retirement now rechecks ownership under the scope lock, and periodic reconciliation drops the tombstone of any scope whose owner exists again, so a scope retired in the remaining race recovers instead of staying dead. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * check project ownership under the scope lock before periodic retirement The listing and the ownership check ran outside the lock that create_folder and upload admission take, so a project recreated with the same id could link a folder that periodic reconciliation then marked retired with auto_sync off. Dropping the tombstone afterwards never restored those fields, leaving the folder unusable. The check now happens inside the scope lock, so a link either precedes retirement and blocks it, or is rejected by the tombstone. * bound scope retirement to folders linked before the ownership check The project rows live in studio.db and the folder rows in rag.db, so the ownership check and the retirement write cannot share a transaction. A second backend process sharing the same home can commit a folder link in that window, and retiring it left auto_sync off with no path back: dropping the tombstone never restored those fields. Retirement now only reaches folders created at or before the moment the scope was found ownerless, so a link from another process keeps its own state. Also close the source descriptor when the uploads root cannot be prepared. os.open succeeded before ensure_dir raised, outside the try that closes it, so a read-only or full uploads directory leaked one descriptor per file in the pass. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com> Co-authored-by: danielhanchen <elliegouldingstuff@gmail.com> Co-authored-by: Maheswar Kumar <110882203+mahiatlinux@users.noreply.github.com>
484 lines
19 KiB
Python
484 lines
19 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
|
|
|
|
"""Verification for Tauri native path signed grants.
|
|
|
|
Rust signs compact ``base64url(payload_json).base64url(hmac)`` grants. The
|
|
frontend can see and forward the grant, but cannot change it without breaking
|
|
the HMAC. The backend verifies the original payload segment bytes, then
|
|
re-stats the path before any native read.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import binascii
|
|
import hashlib
|
|
import hmac
|
|
import importlib
|
|
import json
|
|
import os
|
|
import stat as _stat_module
|
|
import sys
|
|
import threading
|
|
import time
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass, replace
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Collection, Iterable, Iterator, Mapping
|
|
|
|
LEASE_SECRET_ENV = "UNSLOTH_STUDIO_NATIVE_PATH_LEASE_SECRET"
|
|
_MAX_NATIVE_PATH_REDACTIONS = 100
|
|
_MAX_NATIVE_PATH_LABELS = 10_000
|
|
_MIN_LEASE_SECRET_BYTES = 32
|
|
_WINDOWS_STAT_USES_FILE_ID_INFO = os.name == "nt" and sys.version_info >= (3, 12)
|
|
|
|
_REPLAY_LOCK = threading.Lock()
|
|
_USED_NONCES: dict[str, int] = {}
|
|
_REDACTION_LOCK = threading.Lock()
|
|
_NATIVE_PATH_REDACTIONS: list[str] = []
|
|
_NATIVE_PATH_LABELS: dict[str, str] = {}
|
|
_NATIVE_PATH_ENV_LOCK = threading.RLock()
|
|
_SECRET_INIT_LOCK = threading.Lock()
|
|
_CACHED_LEASE_SECRET: bytes | None = None
|
|
_SCRUB_REFCOUNT = 0
|
|
_SCRUB_SAVED_SECRET: str | None = None
|
|
|
|
|
|
class NativePathLeaseError(ValueError):
|
|
"""Raised when a native path grant is missing, invalid, or unsafe."""
|
|
|
|
|
|
def native_gguf_companion_parent_allowed(
|
|
companion_path: str | Path,
|
|
gguf_path: str | Path,
|
|
*,
|
|
allowed_subdirs: Collection[str] = (),
|
|
mtp_search_root: str | Path | None = None,
|
|
) -> bool:
|
|
"""Check whether a GGUF companion is in an allowed directory.
|
|
|
|
``allowed_subdirs`` names the companion directories (``mtp``, ``dspark``)
|
|
this caller may reach into, beside the weight's own. A collection rather
|
|
than one flag per kind: each caller admits exactly the kind it is
|
|
resolving, so an MTP load never accepts a sidecar out of ``dspark/``.
|
|
"""
|
|
companion_parent = Path(companion_path).resolve(strict = True).parent
|
|
gguf_parent = Path(gguf_path).resolve(strict = True).parent
|
|
if companion_parent == gguf_parent:
|
|
return True
|
|
permitted = {name.casefold() for name in allowed_subdirs}
|
|
if companion_parent.name.casefold() not in permitted:
|
|
return False
|
|
allowed_roots = {gguf_parent}
|
|
if mtp_search_root is not None:
|
|
search_root = Path(mtp_search_root).resolve(strict = True)
|
|
if search_root in {gguf_parent, gguf_parent.parent}:
|
|
allowed_roots.add(search_root)
|
|
return companion_parent.parent in allowed_roots
|
|
|
|
|
|
@dataclass(frozen = True)
|
|
class NativePathGrant:
|
|
operation: str
|
|
canonical_path: Path
|
|
path_kind: str
|
|
path_type: str
|
|
source_kind: str
|
|
token_id_hash: str
|
|
display_label: str
|
|
expires_at_ms: int
|
|
size_bytes: int | None
|
|
modified_ms: int | None
|
|
device_id: int | None
|
|
file_id: int | None
|
|
|
|
|
|
def native_path_leases_supported() -> bool:
|
|
try:
|
|
_decode_secret()
|
|
except NativePathLeaseError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def child_env_without_native_path_secret(env: Mapping[str, str] | None = None) -> dict[str, str]:
|
|
"""Return a child-process env with the native path lease secret removed."""
|
|
|
|
if env is None:
|
|
with _NATIVE_PATH_ENV_LOCK:
|
|
cleaned = dict(os.environ)
|
|
else:
|
|
cleaned = dict(env)
|
|
cleaned.pop(LEASE_SECRET_ENV, None)
|
|
return cleaned
|
|
|
|
|
|
def run_without_native_path_secret(
|
|
target: Callable[..., Any] | str, *args: Any, **kwargs: Any
|
|
) -> Any:
|
|
"""Run a multiprocessing child target without the native path lease secret."""
|
|
|
|
# Runs in the spawned child: bind it to the parent's death (Linux), since
|
|
# multiprocessing children cannot be given a preexec_fn by the parent. Shared
|
|
# entrypoint for the inference/export/training/data-recipe workers.
|
|
try:
|
|
from utils.process_lifetime import bind_current_process_to_parent_lifetime
|
|
bind_current_process_to_parent_lifetime()
|
|
except Exception:
|
|
pass
|
|
|
|
global _CACHED_LEASE_SECRET, _SCRUB_SAVED_SECRET
|
|
os.environ.pop(LEASE_SECRET_ENV, None)
|
|
_CACHED_LEASE_SECRET = None
|
|
_SCRUB_SAVED_SECRET = None
|
|
if isinstance(target, str):
|
|
function_name, environment, *args = args
|
|
for key, value in environment.items():
|
|
os.environ[key] = value
|
|
target = getattr(importlib.import_module(target), function_name)
|
|
return target(*args, **kwargs)
|
|
|
|
|
|
@contextmanager
|
|
def native_path_secret_removed_for_child_start() -> Iterator[None]:
|
|
global _SCRUB_REFCOUNT, _SCRUB_SAVED_SECRET, _CACHED_LEASE_SECRET
|
|
with _NATIVE_PATH_ENV_LOCK:
|
|
if _SCRUB_REFCOUNT == 0:
|
|
_SCRUB_SAVED_SECRET = os.environ.pop(LEASE_SECRET_ENV, None)
|
|
_CACHED_LEASE_SECRET = None
|
|
_SCRUB_REFCOUNT += 1
|
|
try:
|
|
yield
|
|
finally:
|
|
_SCRUB_REFCOUNT -= 1
|
|
if _SCRUB_REFCOUNT == 0 and _SCRUB_SAVED_SECRET is not None:
|
|
os.environ[LEASE_SECRET_ENV] = _SCRUB_SAVED_SECRET
|
|
_SCRUB_SAVED_SECRET = None
|
|
|
|
|
|
def verify_native_path_lease(
|
|
lease: str | None,
|
|
*,
|
|
operation: str,
|
|
expected_kind: str | None = None,
|
|
expected_path_type: str | None = None,
|
|
allowed_suffixes: Iterable[str] | None = None,
|
|
) -> NativePathGrant:
|
|
if not lease:
|
|
raise NativePathLeaseError("Native path grant is required.")
|
|
|
|
secret = _decode_secret()
|
|
payload_b64, signature_b64 = _split_lease(lease)
|
|
expected_signature = hmac.new(
|
|
secret,
|
|
payload_b64.encode("ascii"),
|
|
hashlib.sha256,
|
|
).digest()
|
|
supplied_signature = _b64decode(signature_b64)
|
|
if not hmac.compare_digest(expected_signature, supplied_signature):
|
|
raise NativePathLeaseError("Native path grant signature is invalid.")
|
|
|
|
payload = _decode_payload(payload_b64)
|
|
_validate_payload(payload, operation = operation, expected_kind = expected_kind)
|
|
|
|
path = Path(str(payload["canonical_path"]))
|
|
_reject_network_or_device_path(path)
|
|
try:
|
|
signed_lstat = os.lstat(path)
|
|
except OSError as exc:
|
|
raise NativePathLeaseError("Native path is no longer accessible.") from exc
|
|
if _stat_module.S_ISLNK(signed_lstat.st_mode):
|
|
raise NativePathLeaseError("Native path is no longer a regular file.")
|
|
try:
|
|
resolved = path.resolve(strict = True)
|
|
except OSError as exc:
|
|
raise NativePathLeaseError("Native path is no longer accessible.") from exc
|
|
_reject_network_or_device_path(resolved)
|
|
if not _same_native_path(resolved, path):
|
|
raise NativePathLeaseError("Native path grant no longer resolves to the selected path.")
|
|
|
|
identity_options = _identity_options(payload)
|
|
grant = NativePathGrant(
|
|
operation = str(payload["operation"]),
|
|
canonical_path = resolved,
|
|
path_kind = str(payload["path_kind"]),
|
|
path_type = str(payload["path_type"]),
|
|
source_kind = str(payload["source_kind"]),
|
|
token_id_hash = str(payload["token_id_hash"]),
|
|
display_label = str(payload.get("display_label") or resolved.name),
|
|
expires_at_ms = _required_int(payload, "expires_at_ms"),
|
|
size_bytes = _optional_int(payload.get("size_bytes")),
|
|
modified_ms = _optional_int(payload.get("modified_ms")),
|
|
device_id = identity_options[0][0] if identity_options else None,
|
|
file_id = identity_options[0][1] if identity_options else None,
|
|
)
|
|
|
|
if expected_path_type and grant.path_type != expected_path_type:
|
|
raise NativePathLeaseError("Native path grant has the wrong path type.")
|
|
suffixes = tuple(s.lower() for s in (allowed_suffixes or ()))
|
|
if suffixes and resolved.suffix.lower() not in suffixes:
|
|
raise NativePathLeaseError("Native path grant has an unsupported file type.")
|
|
|
|
current_identity = _validate_current_stat(grant, identity_options)
|
|
if current_identity is not None:
|
|
grant = replace(grant, device_id = current_identity[0], file_id = current_identity[1])
|
|
_consume_nonce(str(payload["nonce"]), grant.expires_at_ms)
|
|
_remember_native_path_for_redaction(str(resolved), grant.display_label)
|
|
return grant
|
|
|
|
|
|
def display_label_for_native_path(value: str | None) -> str | None:
|
|
if not value:
|
|
return value
|
|
with _REDACTION_LOCK:
|
|
return _NATIVE_PATH_LABELS.get(value, value)
|
|
|
|
|
|
def is_registered_native_path_label(path_value: str | None, label: str | None) -> bool:
|
|
if not path_value or not label:
|
|
return False
|
|
with _REDACTION_LOCK:
|
|
return _NATIVE_PATH_LABELS.get(path_value) == label
|
|
|
|
|
|
def redact_native_paths(value: str) -> str:
|
|
with _REDACTION_LOCK:
|
|
paths = sorted(_NATIVE_PATH_REDACTIONS, key = len, reverse = True)
|
|
redacted = value
|
|
for path in paths:
|
|
for variant in {path, path.replace("/", "\\"), path.replace("\\", "/")}:
|
|
if variant:
|
|
redacted = redacted.replace(variant, "<native_path>")
|
|
return redacted
|
|
|
|
|
|
def _decode_secret() -> bytes:
|
|
global _CACHED_LEASE_SECRET
|
|
if _CACHED_LEASE_SECRET is not None:
|
|
return _CACHED_LEASE_SECRET
|
|
with _SECRET_INIT_LOCK:
|
|
if _CACHED_LEASE_SECRET is not None:
|
|
return _CACHED_LEASE_SECRET
|
|
with _NATIVE_PATH_ENV_LOCK:
|
|
encoded = os.environ.get(LEASE_SECRET_ENV)
|
|
if encoded is None and _SCRUB_SAVED_SECRET is not None:
|
|
encoded = _SCRUB_SAVED_SECRET
|
|
if not encoded:
|
|
raise NativePathLeaseError("Native path grants require the managed desktop backend.")
|
|
try:
|
|
secret = _b64decode(encoded)
|
|
except Exception as exc:
|
|
raise NativePathLeaseError("Native path grant secret is invalid.") from exc
|
|
if len(secret) < _MIN_LEASE_SECRET_BYTES:
|
|
raise NativePathLeaseError("Native path grant secret is invalid.")
|
|
_CACHED_LEASE_SECRET = secret
|
|
return secret
|
|
|
|
|
|
def _split_lease(lease: str) -> tuple[str, str]:
|
|
if not isinstance(lease, str):
|
|
raise NativePathLeaseError("Native path grant has an invalid format.")
|
|
try:
|
|
lease.encode("ascii")
|
|
except UnicodeEncodeError as exc:
|
|
raise NativePathLeaseError("Native path grant has an invalid format.") from exc
|
|
parts = lease.split(".")
|
|
if len(parts) != 2 or not parts[0] or not parts[1]:
|
|
raise NativePathLeaseError("Native path grant has an invalid format.")
|
|
return parts[0], parts[1]
|
|
|
|
|
|
def _decode_payload(payload_b64: str) -> dict[str, Any]:
|
|
try:
|
|
payload = json.loads(_b64decode(payload_b64).decode("utf-8"))
|
|
except Exception as exc:
|
|
raise NativePathLeaseError("Native path grant payload is invalid.") from exc
|
|
if not isinstance(payload, dict):
|
|
raise NativePathLeaseError("Native path grant payload is invalid.")
|
|
return payload
|
|
|
|
|
|
def _validate_payload(
|
|
payload: dict[str, Any], *, operation: str, expected_kind: str | None
|
|
) -> None:
|
|
required = (
|
|
"version",
|
|
"operation",
|
|
"canonical_path",
|
|
"path_kind",
|
|
"path_type",
|
|
"source_kind",
|
|
"token_id_hash",
|
|
"issued_at_ms",
|
|
"expires_at_ms",
|
|
"nonce",
|
|
)
|
|
missing = [key for key in required if key not in payload]
|
|
if missing:
|
|
raise NativePathLeaseError("Native path grant payload is missing required fields.")
|
|
if _required_int(payload, "version") != 1:
|
|
raise NativePathLeaseError("Native path grant version is unsupported.")
|
|
if payload["operation"] != operation:
|
|
raise NativePathLeaseError("Native path grant operation is invalid.")
|
|
if expected_kind and payload["path_kind"] != expected_kind:
|
|
raise NativePathLeaseError("Native path grant kind is invalid.")
|
|
now_ms = int(time.time() * 1000)
|
|
issued_at_ms = _required_int(payload, "issued_at_ms")
|
|
expires_at_ms = _required_int(payload, "expires_at_ms")
|
|
if issued_at_ms >= expires_at_ms:
|
|
raise NativePathLeaseError("Native path grant timestamps are inconsistent.")
|
|
if expires_at_ms <= now_ms:
|
|
raise NativePathLeaseError("Native path grant has expired.")
|
|
if issued_at_ms > now_ms + 30_000:
|
|
raise NativePathLeaseError("Native path grant issue time is invalid.")
|
|
for key in ("canonical_path", "nonce", "token_id_hash", "display_label"):
|
|
raw = payload.get(key)
|
|
if raw is None:
|
|
continue
|
|
if "\x00" in str(raw):
|
|
raise NativePathLeaseError("Native path grant contains invalid characters.")
|
|
|
|
|
|
def _validate_current_stat(
|
|
grant: NativePathGrant, identity_options: tuple[tuple[int, int], ...]
|
|
) -> tuple[int, int] | None:
|
|
try:
|
|
st = os.lstat(grant.canonical_path)
|
|
except OSError as exc:
|
|
raise NativePathLeaseError("Native path is no longer accessible.") from exc
|
|
if _stat_module.S_ISLNK(st.st_mode):
|
|
raise NativePathLeaseError("Native path is no longer a regular file.")
|
|
if grant.path_type == "file":
|
|
if not _stat_module.S_ISREG(st.st_mode):
|
|
raise NativePathLeaseError("Native path is no longer a regular file.")
|
|
elif grant.path_type == "directory":
|
|
if not _stat_module.S_ISDIR(st.st_mode):
|
|
raise NativePathLeaseError("Native path is no longer a directory.")
|
|
else:
|
|
raise NativePathLeaseError("Native path grant has an unsupported path type.")
|
|
|
|
if grant.size_bytes is not None and st.st_size != grant.size_bytes:
|
|
raise NativePathLeaseError("Native path changed after it was selected.")
|
|
current_modified_ms = int(st.st_mtime_ns // 1_000_000)
|
|
if grant.modified_ms is not None and current_modified_ms != grant.modified_ms:
|
|
raise NativePathLeaseError("Native path changed after it was selected.")
|
|
if grant.path_kind == "document-folder" and not identity_options:
|
|
raise NativePathLeaseError("Native path grant is missing its folder identity.")
|
|
current_identity = (st.st_dev, st.st_ino)
|
|
expected_identity = _runtime_identity(identity_options)
|
|
if expected_identity is not None and current_identity != expected_identity:
|
|
raise NativePathLeaseError("Native path changed after it was selected.")
|
|
return current_identity if expected_identity is not None else None
|
|
|
|
|
|
def _consume_nonce(nonce: str, expires_at_ms: int) -> None:
|
|
now_ms = int(time.time() * 1000)
|
|
with _REPLAY_LOCK:
|
|
for key, expiry in list(_USED_NONCES.items()):
|
|
if expiry <= now_ms:
|
|
_USED_NONCES.pop(key, None)
|
|
if nonce in _USED_NONCES:
|
|
raise NativePathLeaseError("Native path grant was already used.")
|
|
_USED_NONCES[nonce] = expires_at_ms
|
|
|
|
|
|
def _remember_native_path_for_redaction(path: str, display_label: str) -> None:
|
|
with _REDACTION_LOCK:
|
|
_NATIVE_PATH_LABELS[path] = display_label
|
|
if len(_NATIVE_PATH_LABELS) > _MAX_NATIVE_PATH_LABELS:
|
|
excess = len(_NATIVE_PATH_LABELS) - _MAX_NATIVE_PATH_LABELS
|
|
for stale_path in list(_NATIVE_PATH_LABELS.keys())[:excess]:
|
|
_NATIVE_PATH_LABELS.pop(stale_path, None)
|
|
if path in _NATIVE_PATH_REDACTIONS:
|
|
return
|
|
_NATIVE_PATH_REDACTIONS.append(path)
|
|
del _NATIVE_PATH_REDACTIONS[:-_MAX_NATIVE_PATH_REDACTIONS]
|
|
|
|
|
|
def _reject_network_or_device_path(path: Path) -> None:
|
|
text = str(path)
|
|
if os.name == "nt":
|
|
normalized = text.replace("/", "\\").lower()
|
|
if normalized.startswith("\\\\?\\"):
|
|
rest = normalized[4:]
|
|
is_local_drive = len(rest) >= 3 and rest[0].isalpha() and rest[1:3] == ":\\"
|
|
if not is_local_drive:
|
|
raise NativePathLeaseError("Network paths are not supported for native grants.")
|
|
elif normalized.startswith("\\\\"):
|
|
raise NativePathLeaseError("Network paths are not supported for native grants.")
|
|
if os.name != "nt":
|
|
for root in ("/dev", "/proc", "/sys"):
|
|
if path.is_relative_to(root):
|
|
raise NativePathLeaseError("Device and virtual filesystem paths are not supported.")
|
|
if "\x00" in text:
|
|
raise NativePathLeaseError("Native path contains invalid characters.")
|
|
|
|
|
|
def _b64decode(value: str) -> bytes:
|
|
try:
|
|
padding = "=" * (-len(value) % 4)
|
|
return base64.urlsafe_b64decode((value + padding).encode("ascii"))
|
|
except (UnicodeEncodeError, binascii.Error, ValueError) as exc:
|
|
raise NativePathLeaseError("Native path grant has an invalid format.") from exc
|
|
|
|
|
|
def _same_native_path(resolved: Path, signed: Path) -> bool:
|
|
try:
|
|
return resolved.samefile(signed)
|
|
except OSError:
|
|
return os.path.normcase(str(resolved)) == os.path.normcase(str(signed))
|
|
|
|
|
|
def _optional_int(value: Any) -> int | None:
|
|
if value is None:
|
|
return None
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError) as exc:
|
|
raise NativePathLeaseError("Native path grant payload is invalid.") from exc
|
|
|
|
|
|
def _identity_options(payload: dict[str, Any]) -> tuple[tuple[int, int], ...]:
|
|
devices = _optional_identities(payload.get("device_id"))
|
|
files = _optional_identities(payload.get("file_id"))
|
|
if devices is None and files is None:
|
|
return ()
|
|
if devices is None or files is None or len(devices) != len(files):
|
|
raise NativePathLeaseError("Native path grant payload is invalid.")
|
|
return tuple(zip(devices, files))
|
|
|
|
|
|
def _runtime_identity(identity_options: tuple[tuple[int, int], ...]) -> tuple[int, int] | None:
|
|
if not identity_options:
|
|
return None
|
|
if len(identity_options) == 1:
|
|
return identity_options[0]
|
|
# Rust encodes the legacy Win32 pair first and FILE_ID_INFO second.
|
|
return identity_options[1] if _WINDOWS_STAT_USES_FILE_ID_INFO else identity_options[0]
|
|
|
|
|
|
def _optional_identities(value: Any) -> tuple[int, ...] | None:
|
|
if value is None:
|
|
return None
|
|
if not isinstance(value, str) or value != value.lower():
|
|
raise NativePathLeaseError("Native path grant payload is invalid.")
|
|
parts = value.split(":")
|
|
if not 1 <= len(parts) <= 2 or any(
|
|
not part or any(char not in "0123456789abcdef" for char in part) for part in parts
|
|
):
|
|
raise NativePathLeaseError("Native path grant payload is invalid.")
|
|
try:
|
|
return tuple(int(part, 16) for part in parts)
|
|
except ValueError as exc:
|
|
raise NativePathLeaseError("Native path grant payload is invalid.") from exc
|
|
|
|
|
|
def _required_int(payload: dict[str, Any], key: str) -> int:
|
|
raw = payload.get(key)
|
|
if raw is None:
|
|
raise NativePathLeaseError("Native path grant payload is missing required fields.")
|
|
try:
|
|
return int(raw)
|
|
except (TypeError, ValueError) as exc:
|
|
raise NativePathLeaseError("Native path grant payload is invalid.") from exc
|