diff --git a/.gitattributes b/.gitattributes index d9d5ea8c3..5a5cf550b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,7 @@ # Git LFS configuration for website media assets. # Strict donor parity: source images, generated WebP derivatives, and # Hugo's cached processed images are committed and fetched via Git LFS. +companion/src/** text eol=lf archive_testdata/*.zip -text website/assets/images/** filter=lfs diff=lfs merge=lfs -text website/resources/_gen/images/** filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/devcontainer-validate.yml b/.github/workflows/devcontainer-validate.yml index 9d1cc7bf0..7cfde1217 100644 --- a/.github/workflows/devcontainer-validate.yml +++ b/.github/workflows/devcontainer-validate.yml @@ -68,8 +68,9 @@ jobs: - name: Report devcontainer runtime contract shell: bash run: | - docker run --rm --entrypoint sh sambee:devcontainer-validation -c ' + docker run --rm --entrypoint sh -v "$PWD:/workspace:ro" -e PYTHONPATH=/workspace/backend sambee:devcontainer-validation -c ' python --version python -c "import sqlite3; print(\"sqlite3 module: \" + sqlite3.sqlite_version)" + python -c "from app.services.directory_index import validate_sqlite_runtime; validate_sqlite_runtime(); print(\"Directory-index SQLite features verified\")" dpkg-query -W -f="\${Package} \${Version}\\n" libsqlite3-0 ' diff --git a/.github/workflows/docker-image-validate.yml b/.github/workflows/docker-image-validate.yml index 8afdadfa7..0b24feb37 100644 --- a/.github/workflows/docker-image-validate.yml +++ b/.github/workflows/docker-image-validate.yml @@ -246,6 +246,7 @@ jobs: docker exec sambee-smoke-${{ matrix.platform_id }} sh -c ' python --version python -c "import sqlite3; print(\"sqlite3 module: \" + sqlite3.sqlite_version)" + python -c "from app.services.directory_index import validate_sqlite_runtime; validate_sqlite_runtime(); print(\"Directory-index SQLite features verified\")" dpkg-query -W -f="\${Package} \${Version}\\n" libsqlite3-0 ' diff --git a/.github/workflows/verify-companion-build.yml b/.github/workflows/verify-companion-build.yml index b49369fd7..50b9854da 100644 --- a/.github/workflows/verify-companion-build.yml +++ b/.github/workflows/verify-companion-build.yml @@ -43,6 +43,7 @@ jobs: needs: change-scope if: needs.change-scope.outputs.requires-build == 'true' runs-on: windows-latest + timeout-minutes: 45 env: CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse steps: @@ -66,11 +67,20 @@ jobs: - name: Run Companion checks shell: pwsh run: | + New-Item -ItemType Directory -Force companion/recovery-report | Out-Null + @{ source_sha = '${{ github.sha }}'; run_id = '${{ github.run_id }}'; target = 'windows-x64' } | + ConvertTo-Json | Set-Content companion/recovery-report/source.json cd companion npx tsc --noEmit + if ($LASTEXITCODE -ne 0) { throw 'Companion TypeScript check failed.' } npm run lint + if ($LASTEXITCODE -ne 0) { throw 'Companion lint failed.' } cd src-tauri cargo test + if ($LASTEXITCODE -ne 0) { throw 'Native Rust tests failed.' } + cargo test --features recovery-verification server::directory_manager::tests -- --test-threads=2 2>&1 | + Tee-Object -FilePath ../recovery-report/native-tests.log + if ($LASTEXITCODE -ne 0) { throw 'Native recovery tests failed.' } - name: Assert verification-only secret boundary shell: pwsh @@ -102,7 +112,20 @@ jobs: - name: Build Windows x64 package shell: pwsh - run: cd companion; npm run tauri -- build --config $env:TAURI_CI_CONFIG --target x86_64-pc-windows-msvc --bundles nsis -v + run: | + cd companion + npm run tauri -- build --config $env:TAURI_CI_CONFIG --target x86_64-pc-windows-msvc --features recovery-verification --bundles nsis -v + if ($LASTEXITCODE -ne 0) { throw 'Verification package build failed.' } + + - name: Run packaged-process recovery smoke test + timeout-minutes: 5 + shell: pwsh + run: | + python companion/scripts/recovery-smoke.py ` + --executable companion/src-tauri/target/x86_64-pc-windows-msvc/release/sambee-companion.exe ` + --report-dir companion/recovery-report ` + --source-sha '${{ github.sha }}' --run-id '${{ github.run_id }}' + if ($LASTEXITCODE -ne 0) { throw 'Packaged recovery smoke test failed. Inspect recovery-report.' } - name: Assert updater artifacts are absent shell: pwsh @@ -113,9 +136,13 @@ jobs: } - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 + if: ${{ always() }} with: - name: companion-verification-windows-x64-${{ github.run_id }} - path: companion/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/ + name: companion-verification-windows-x64-${{ github.sha }}-${{ github.run_id }} + path: | + companion/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/ + companion/src-tauri/target/x86_64-pc-windows-msvc/release/sambee-companion.exe + companion/recovery-report/ if-no-files-found: error retention-days: 7 diff --git a/Dockerfile b/Dockerfile index 696db9b26..b6a3d980b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -103,6 +103,7 @@ COPY frontend/package*.json ./ COPY frontend/scripts ./scripts RUN npm ci COPY frontend/ ./ +COPY shared/ /shared/ COPY VERSION /VERSION COPY GIT_COMMIT /GIT_COMMIT RUN npm run build diff --git a/VERSION b/VERSION index f6ee96254..2ce458919 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.26 +2.0.27 diff --git a/backend/app/api/_smb_helpers.py b/backend/app/api/_smb_helpers.py index 2a5ec8726..acdde1add 100644 --- a/backend/app/api/_smb_helpers.py +++ b/backend/app/api/_smb_helpers.py @@ -8,6 +8,7 @@ from app.core.security import decrypt_password from app.models.connection import Connection from app.storage.smb import SMBBackend +from app.storage.smb_errors import SMBConnectionCapacityError, SMBConnectionRetired, SMBMutationUncertain BACKEND_DISCONNECT_TIMEOUT_SECONDS = 5.0 ITEM_NOT_FOUND_CODE = "item_not_found" @@ -20,6 +21,26 @@ def file_target_http_error(error: Exception, *, requires_file: bool = True) -> HTTPException: """Map physical item failures without exposing storage or operating-system details.""" + if isinstance(error, SMBMutationUncertain): + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={"code": "smb_mutation_uncertain", "message": str(error), "recoverable": False, "mutation_uncertain": True}, + ) + if isinstance(error, SMBConnectionRetired): + return HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail={ + "code": "smb_recovery_busy", + "message": ( + "SMB recovery capacity is occupied. Wait and retry. If workers remain blocked, restart the service." + if isinstance(error, SMBConnectionCapacityError) + else "SMB recovery is busy. Wait briefly, then retry the request." + ), + "recoverable": True, + "retry_after_ms": error.retry_after_seconds * 1000, + }, + headers={"Retry-After": str(error.retry_after_seconds)}, + ) if isinstance(error, FileNotFoundError): status_code, code, message = ( status.HTTP_404_NOT_FOUND, diff --git a/backend/app/api/browser.py b/backend/app/api/browser.py index 4185bdd2f..59f208589 100644 --- a/backend/app/api/browser.py +++ b/backend/app/api/browser.py @@ -1,6 +1,7 @@ import asyncio import json import secrets +import threading import unicodedata import uuid from collections.abc import AsyncIterator, Awaitable, Callable @@ -97,6 +98,7 @@ cross_connection_move, final_target_policy_check, ) +from app.services.directory_index import MAX_QUERY_BYTES from app.services.history_common import ( LOCAL_DRIVE_PREFIX, directory_match_rank, @@ -123,11 +125,13 @@ from app.services.system_settings import get_integer_setting_value from app.services.target_resolution import TargetResolutionDisposition, TargetResolutionPolicy, TargetSnapshot, resolve_target_mutation from app.storage.smb import SMBBackend +from app.storage.smb_errors import SMB_RECOVERY_RETRY_SECONDS, SMBConnectionRetired router = APIRouter() logger = get_logger(__name__) DIRECTORY_LIST_ROUTE_TIMEOUT_SECONDS = 35.0 +DIRECTORY_SEARCH_RETRY_SECONDS = 5 TRANSFER_RECEIPT_TTL_SECONDS = 5 * 60 TRANSFER_OPERATION_TTL = timedelta(seconds=TRANSFER_RECEIPT_TTL_SECONDS) TRANSFER_OPERATION_PROGRESS_PERSIST_INTERVAL_BYTES = 1024 * 1024 @@ -456,8 +460,12 @@ async def get_recent_directories( session: Session = Depends(get_session), ) -> RecentDirectorySearchRead: set_user(current_user.username) + try: + results = search_recent_directories(query=q, limit=limit, current_user=current_user, session=session) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc return RecentDirectorySearchRead( - results=search_recent_directories(query=q, limit=limit, current_user=current_user, session=session), + results=results, result_limit=get_recent_file_result_limit(limit=limit, session=session), ) @@ -547,8 +555,12 @@ async def get_recent_files( ) -> RecentFileSearchRead: set_user(current_user.username) result_limit = get_recent_file_result_limit(limit=limit, session=session) + try: + results = search_recent_files(query=q, limit=result_limit, current_user=current_user, session=session) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc return RecentFileSearchRead( - results=search_recent_files(query=q, limit=result_limit, current_user=current_user, session=session), + results=results, result_limit=result_limit, ) @@ -946,19 +958,24 @@ async def list_directory( status_code=status.HTTP_403_FORBIDDEN, detail={"code": "directory_access_denied", "message": "You do not have permission to open this folder."}, ) from error + except SMBConnectionRetired as error: + raise file_target_http_error(error, requires_file=False) from error except asyncio.TimeoutError: - logger.error( - f"Timed out listing directory: connection_id={connection_id}, path='{path}', host={connection.host}, share={connection.share_name}" - ) + logger.warning("SMB directory listing timed out. Active work remains owned until its worker exits.") raise HTTPException( status_code=status.HTTP_504_GATEWAY_TIMEOUT, - detail="Directory listing timed out. The remote share did not respond in time.", + detail={ + "code": "directory_timeout", + "message": "Directory listing timed out. Check the connection and retry.", + "recoverable": True, + "retry_after_ms": SMB_RECOVERY_RETRY_SECONDS * 1000, + }, + headers={"Retry-After": str(SMB_RECOVERY_RETRY_SECONDS)}, ) except Exception as e: logger.error( - f"Failed to list directory: connection_id={connection_id}, path='{path}', " - f"host={connection.host}, share={connection.share_name}, " - f"error={type(e).__name__}: {e}", + "Directory listing failed. Check the connection and retry.", + extra={"event": "directory_listing_failed", "operation": "list_directory", "error_type": type(e).__name__}, exc_info=True, ) raise HTTPException( @@ -1056,14 +1073,14 @@ async def get_file_info( @router.get("/{connection_id}/directories", response_model=DirectorySearchResult) async def search_directories( connection_id: uuid.UUID, - q: str = Query("", description="Search query for directory names"), + q: str = Query("", max_length=MAX_QUERY_BYTES, description="Directory search text; shared UTF-8 byte and term limits apply"), include_dot_directories: bool = Query(False, description="Whether to include directories whose path contains dot-prefixed segments"), current_user: User = Depends(get_current_user_with_auth_check), session: Session = Depends(get_session), ) -> DirectorySearchResult: """Search for directories across the entire connection. - Returns matching directory paths from the in-memory cache. + Returns matching directory paths from the source index. If the cache is not yet built, triggers an initial scan and returns partial results. The cache_state field indicates indexing status. """ @@ -1073,8 +1090,16 @@ async def search_directories( connection = _get_connection_or_404(session, current_user, connection_id) + from app.core.directory_index_contract import CONTRACT_VERSION + from app.services.directory_index import search_terms + + try: + search_terms(q.replace("\\", "/")) + except ValueError as error: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error + try: - from app.services.directory_cache import get_directory_cache_manager + from app.services.directory_cache import SQLiteConnectionDirectoryCache, get_directory_cache_manager cache_manager = get_directory_cache_manager() cache = await cache_manager.get_or_create_cache( @@ -1088,7 +1113,33 @@ async def search_directories( source_revision=connection.updated_at.isoformat(), ) - results, total_matches = cache.search(q, include_dot_directories=include_dot_directories) if q else ([], 0) + if isinstance(cache, SQLiteConnectionDirectoryCache): + cancelled = threading.Event() + try: + response = await asyncio.to_thread( + cache.search_index, q, include_dot_directories=include_dot_directories, cancelled=cancelled + ) + except asyncio.CancelledError: + cancelled.set() + raise + return DirectorySearchResult( + results=[row.path for row in response.results], + match_ranks={row.path: row.match_rank for row in response.results}, + matched_term_counts={row.path: row.matched_term_count for row in response.results}, + total_matches=response.total_matches, + cache_state=cache.state.value, + directory_count=response.directory_count, + search_contract_version=CONTRACT_VERSION, + accepted_revision=response.accepted_revision, + coverage=response.coverage, + coverage_issue_count=response.issue_count, + last_verified_at_ms=response.last_verified_at_ms, + status_message=cache.scan_error, + monitoring_state=cache.monitoring_state, + retry_after_ms=cache.retry_after_ms, + ) + + results, total_matches = await asyncio.to_thread(cache.search, q, include_dot_directories=include_dot_directories) if q else ([], 0) normalized_query = normalize_history_search_text(q.replace("\\", "/").strip()) return DirectorySearchResult( @@ -1099,15 +1150,25 @@ async def search_directories( directory_count=cache.directory_count, ) - except Exception as e: - logger.error( - f"Failed to search directories: connection_id={connection_id}, query='{q}', include_dot_directories={include_dot_directories}, error={type(e).__name__}: {e}", - exc_info=True, - ) + except HTTPException: + raise + except (TimeoutError, InterruptedError) as error: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail={ + "code": "directory_temporarily_unavailable", + "message": str(error), + "recoverable": True, + "retry_after_ms": DIRECTORY_SEARCH_RETRY_SECONDS * 1000, + }, + headers={"Retry-After": str(DIRECTORY_SEARCH_RETRY_SECONDS)}, + ) from error + except Exception as error: + logger.error("Directory search failed (%s).", type(error).__name__) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to search directories: {str(e)}", - ) + detail="Directory search failed. Check connection access and local index storage, then retry.", + ) from error # ============================================================================ @@ -1463,6 +1524,8 @@ async def upload_file( ) except HTTPException: raise + except SMBConnectionRetired as error: + raise file_target_http_error(error) from error except TimeoutError: raise HTTPException( status_code=status.HTTP_504_GATEWAY_TIMEOUT, @@ -2102,6 +2165,8 @@ async def delete_item( logger.info(f"Deleted item: connection_id={connection_id}, path='{path}', user={current_user.username}") + except SMBConnectionRetired as error: + raise file_target_http_error(error) from error except FileNotFoundError: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -2295,6 +2360,8 @@ async def rename_item( logger.info(f"Renamed item: connection_id={connection_id}, '{path}' -> '{new_name}', user={current_user.username}") return file_info + except SMBConnectionRetired as error: + raise file_target_http_error(error) from error except FileNotFoundError: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -2415,6 +2482,8 @@ async def create_item( status_code=status.HTTP_504_GATEWAY_TIMEOUT, detail="Create timed out. The remote share did not respond in time.", ) + except SMBConnectionRetired as error: + raise file_target_http_error(error) from error except OSError as e: logger.error( f"Failed to create item: connection_id={connection_id}, path='{new_item_path}', error={type(e).__name__}: {e}", @@ -2758,6 +2827,10 @@ async def copy_direct(source_info: FileInfo, disposition: TargetResolutionDispos except HTTPException as error: await _record_transfer_http_error(current_user, body, error) raise + except SMBConnectionRetired as error: + response = file_target_http_error(error) + await _record_transfer_http_error(current_user, body, response) + raise response from error except TimeoutError: await _raise_recorded_transfer_http_error( current_user, @@ -2926,6 +2999,10 @@ async def move_direct(source_info: FileInfo, disposition: TargetResolutionDispos except HTTPException as error: await _record_transfer_http_error(current_user, body, error) raise + except SMBConnectionRetired as error: + response = file_target_http_error(error) + await _record_transfer_http_error(current_user, body, response) + raise response from error except TimeoutError: await _raise_recorded_transfer_http_error( current_user, @@ -3311,12 +3388,12 @@ def _rename_in_directory_cache(connection_id: str, old_path: str, new_path: str) # _add_to_directory_cache # def _add_to_directory_cache(connection_id: str, path: str) -> None: - """Add a newly created directory to the directory cache, if it exists. + """Reconcile a mutation destination, which can be a file or directory. Silently ignores errors — cache updates must never break the main flow. """ - from app.services.directory_cache import get_directory_cache_manager + from app.services.directory_cache import SQLiteConnectionDirectoryCache, get_directory_cache_manager try: cache_manager = get_directory_cache_manager() @@ -3324,6 +3401,9 @@ def _add_to_directory_cache(connection_id: str, path: str) -> None: if cache is None: return - cache.add_directories([path]) + if isinstance(cache, SQLiteConnectionDirectoryCache): + cache.observe_parent(path.strip("/").rpartition("/")[0]) + else: + cache.add_directories([path]) except Exception: pass diff --git a/backend/app/api/system_settings.py b/backend/app/api/system_settings.py index 2c296c876..efc00d9f2 100644 --- a/backend/app/api/system_settings.py +++ b/backend/app/api/system_settings.py @@ -1,16 +1,25 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, status +import asyncio + +from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlmodel import Session from app.core.authorization import Capability from app.core.logging import set_user from app.core.security import require_capability +from app.core.system_setting_definitions import DirectoryIndexPolicyScope from app.db.database import get_immediate_session, get_session from app.models.system_settings import ( AboutSettingsRead, AdvancedSystemSettingsRead, AdvancedSystemSettingUpdate, + DirectoryIndexSettingsRead, + DirectoryIndexSettingUpdate, + DirectoryPolicyGrantBinding, + DirectoryPolicyGrantRead, + DirectoryPolicyGrantReceipt, + DirectoryPolicyGrantRedeem, FileSearchSettingsRead, FileSearchSettingsUpdate, FileSearchSettingsUpdateResult, @@ -26,6 +35,7 @@ from app.services.system_settings import ( build_about_settings_read, build_advanced_system_settings_read, + build_directory_index_settings_read, build_file_search_settings_read, build_network_settings_read, build_public_support_report_read, @@ -34,6 +44,7 @@ retire_smb_runtime_policy, smb_policy_will_change, update_advanced_system_settings, + update_directory_index_settings, update_file_search_settings, update_network_settings, update_smb_settings, @@ -42,6 +53,66 @@ router = APIRouter() +@router.post("/settings/directory-index/companion/grants", response_model=DirectoryPolicyGrantRead) +async def post_directory_policy_grant( + payload: DirectoryPolicyGrantBinding, + request: Request, + session: Session = Depends(get_immediate_session), + current_user: User = Depends(require_capability(Capability.ACCESS_ADMIN_SETTINGS)), +) -> DirectoryPolicyGrantRead: + from app.services.directory_policy_grants import issue_directory_policy_grant + + if request.headers.get("origin") != payload.origin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Policy origin does not match the browser origin. Request approval from the connected backend origin.", + ) + return issue_directory_policy_grant(payload, current_user, session) + + +@router.post("/settings/directory-index/companion/grants/redeem", response_model=DirectoryPolicyGrantReceipt) +async def post_directory_policy_grant_redemption( + payload: DirectoryPolicyGrantRedeem, + session: Session = Depends(get_immediate_session), +) -> DirectoryPolicyGrantReceipt: + from app.services.directory_policy_grants import redeem_directory_policy_grant + + return redeem_directory_policy_grant(payload, session) + + +@router.get("/settings/directory-index/{scope}", response_model=DirectoryIndexSettingsRead) +async def get_directory_index_settings( + scope: DirectoryIndexPolicyScope, + current_user: User = Depends(require_capability(Capability.ACCESS_ADMIN_SETTINGS)), + session: Session = Depends(get_session), +) -> DirectoryIndexSettingsRead: + set_user(current_user.username) + try: + return build_directory_index_settings_read(scope, session) + except ValueError as error: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error + + +@router.put("/settings/directory-index/{scope}", response_model=DirectoryIndexSettingUpdate) +async def put_directory_index_settings( + scope: DirectoryIndexPolicyScope, + payload: DirectoryIndexSettingUpdate, + session: Session = Depends(get_immediate_session), + current_user: User = Depends(require_capability(Capability.ACCESS_ADMIN_SETTINGS)), +) -> DirectoryIndexSettingUpdate: + set_user(current_user.username) + try: + updated = update_directory_index_settings(scope, payload, updated_by_user_id=current_user.id, session=session) + except ValueError as error: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error + if scope is DirectoryIndexPolicyScope.BACKEND: + from app.services.directory_cache import get_directory_cache_manager + + policy = build_directory_index_settings_read(scope, session).policy + await asyncio.to_thread(get_directory_cache_manager().apply_policy, policy) + return updated + + @router.get("/settings/file-search", response_model=FileSearchSettingsRead) async def get_file_search_settings( current_user: User = Depends(require_capability(Capability.ACCESS_ADMIN_SETTINGS)), diff --git a/backend/app/core/directory_index_contract.py b/backend/app/core/directory_index_contract.py new file mode 100644 index 000000000..2a04f5e12 --- /dev/null +++ b/backend/app/core/directory_index_contract.py @@ -0,0 +1,116 @@ +"""Validated language-neutral directory-index limits and resource-policy fields.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from types import MappingProxyType +from typing import Final, Literal, Mapping + +CONTRACT_VERSION: Final[Literal[1]] = 1 +MAX_SQL_INTEGER: Final = (1 << 63) - 1 +MAX_WIRE_INTEGER: Final = (1 << 53) - 1 +DIRECTORY_POLICY_TOKEN_CLASS: Final = "directory_index_policy_grant" +DIRECTORY_POLICY_TOKEN_PURPOSE: Final = "approve_directory_index_policy" +DIRECTORY_POLICY_TOKEN_CLAIM: Final = "companion_directory_policy" +DIRECTORY_POLICY_GRANT_SECONDS: Final = 60 +MAX_DIRECTORY_POLICY_GRANTS: Final = 4096 +MAX_DIRECTORY_POLICY_ORIGIN_BYTES: Final = 2048 +MIN_DIRECTORY_POLICY_CHALLENGE_BYTES: Final = 32 +MAX_DIRECTORY_POLICY_CHALLENGE_BYTES: Final = 128 +MAX_DIRECTORY_POLICY_TOKEN_BYTES: Final = 8192 +CONTRACT_PATH_CANDIDATES = tuple( + Path(__file__).resolve().parents[level] / "shared" / "directory-index" / "contract.json" for level in (3, 2) +) +CONTRACT_PATH = next((path for path in CONTRACT_PATH_CANDIDATES if path.is_file()), CONTRACT_PATH_CANDIDATES[0]) +POLICY_FIELDS = ( + "disk_budget_bytes", + "inactive_retention_seconds", + "max_retained_inactive_sources", + "page_cache_kib", + "healthy_verification_seconds", + "degraded_verification_seconds", +) +LIMIT_FIELDS = frozenset( + { + "schema_version", + "normalization_version", + "traversal_version", + "max_query_bytes", + "max_query_terms", + "max_result_limit", + "row_batch_size", + "query_workers", + "scan_workers", + "busy_timeout_ms", + "progress_steps", + "query_timeout_seconds", + "status_publication_ms", + "event_coalescing_ms", + "idle_release_seconds", + "minimum_full_pass_spacing_seconds", + "max_pending_observations", + "max_observation_bytes", + "max_source_depth", + "max_source_path_bytes", + "minimum_sqlite_version_number", + "write_headroom_bytes", + "maintenance_page_batch", + "checkpoint_wal_bytes", + "lease_cleanup_batch", + } +) + + +@dataclass(frozen=True) +class DirectoryPolicyField: + label: str + description: str + default: int + minimum: int + maximum: int + step: int + + def validate(self, value: int) -> int: + if type(value) is not int or not self.minimum <= value <= self.maximum: + raise ValueError(f"{self.label} must be an integer between {self.minimum} and {self.maximum}.") + return value + + +@dataclass(frozen=True) +class DirectoryIndexContract: + limits: Mapping[str, int] + policy: Mapping[str, DirectoryPolicyField] + + +def load_directory_index_contract(path: Path = CONTRACT_PATH) -> DirectoryIndexContract: + document = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(document, dict) or document.get("version") != CONTRACT_VERSION or type(document.get("version")) is not int: + raise ValueError("Directory-index contract version is unsupported. Restore the shared contract file.") + limits = document.get("limits") + policy = document.get("policy") + if not isinstance(limits, dict) or set(limits) != LIMIT_FIELDS or any(type(value) is not int or value < 1 for value in limits.values()): + raise ValueError("Directory-index limits are invalid. Restore the shared contract file.") + if not isinstance(policy, dict) or set(policy) != set(POLICY_FIELDS): + raise ValueError("Directory-index policy fields are invalid. Restore the shared contract file.") + parsed: dict[str, DirectoryPolicyField] = {} + for name in POLICY_FIELDS: + entry = policy[name] + if not isinstance(entry, dict) or set(entry) != {"label", "description", "default", "minimum", "maximum", "step"}: + raise ValueError("Directory-index policy definition is invalid. Restore the shared contract file.") + if any(type(entry[key]) is not int or entry[key] < 1 for key in ("default", "minimum", "maximum", "step")): + raise ValueError("Directory-index policy bounds must be positive integers. Restore the shared contract file.") + if any(not isinstance(entry[key], str) or not entry[key] for key in ("label", "description")): + raise ValueError("Directory-index policy descriptions are missing. Restore the shared contract file.") + field = DirectoryPolicyField(**entry) + field.validate(field.default) + if field.minimum > field.maximum or field.step > field.maximum - field.minimum: + raise ValueError("Directory-index policy bounds are inconsistent. Restore the shared contract file.") + parsed[name] = field + if parsed["degraded_verification_seconds"].default > parsed["healthy_verification_seconds"].default: + raise ValueError("Degraded verification must not exceed healthy verification. Restore the shared contract file.") + return DirectoryIndexContract(MappingProxyType(limits), MappingProxyType(parsed)) + + +DIRECTORY_INDEX_CONTRACT = load_directory_index_contract() diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py index aa6b73931..cbf8cf8cb 100644 --- a/backend/app/core/logging.py +++ b/backend/app/core/logging.py @@ -6,9 +6,11 @@ import logging import re +import traceback import uuid from collections.abc import MutableMapping from contextvars import ContextVar +from copy import copy from typing import Any, Optional # Context variables for request tracking @@ -114,6 +116,58 @@ def filter(self, record: logging.LogRecord) -> bool: return True +class RecoveryLogFormatter(logging.Formatter): + """Render safe diagnostic fields and omit private recovery exception text.""" + + _FIELDS = ( + "event", + "operation", + "stage", + "source_id", + "generation_id", + "duration_ms", + "retry_seconds", + "retry_after_ms", + "retained_worker_count", + "error_type", + "errno", + "sqlite_errorcode", + ) + _PROTOCOL_LOGGERS = ("smbclient", "smbprotocol") + _RECOVERY_LOGGERS = ("app.storage.smb", "app.services.directory_", "app.api.browser", *_PROTOCOL_LOGGERS) + + def format(self, record: logging.LogRecord) -> str: + safe_record = copy(record) + if record.name.startswith(self._PROTOCOL_LOGGERS): + safe_record.msg = "SMB client diagnostic. Check source access if the operation failed." + safe_record.args = () + if record.name.startswith(self._RECOVERY_LOGGERS) and record.exc_info: + error_type, error, trace = record.exc_info + causes = [] + seen = set() + while error is not None and id(error) not in seen: + seen.add(id(error)) + for field in ("errno", "sqlite_errorcode"): + code = getattr(error, field, None) + if isinstance(code, int) and getattr(safe_record, field, None) is None: + setattr(safe_record, field, code) + frames = " -> ".join(f"{frame.name}:{frame.lineno}" for frame in traceback.extract_tb(trace)) + causes.append(f"{frames}; {error_type.__name__ if error_type else 'UnknownError'}") + error = error.__cause__ or (None if error.__suppress_context__ else error.__context__) if error is not None else None + if error is None: + break + error_type, trace = type(error), error.__traceback__ + safe_record.exc_info = None + safe_record.exc_text = f"Recovery traceback: {'; caused by '.join(causes)}" + message = super().format(safe_record) + fields = {} + for key in self._FIELDS: + value = getattr(safe_record, key, None) + if isinstance(value, (int, float)) or (isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9_.:-]{1,80}", value)): + fields[key] = value + return f"{message} | {format_audit_fields(**fields)}" if fields else message + + class AccessLogLevelFilter(logging.Filter): """Classify access events as DEBUG after their logger-level threshold is applied.""" diff --git a/backend/app/core/security.py b/backend/app/core/security.py index b4a4d4fbd..cdfb7b2bf 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -17,6 +17,7 @@ from app.core.authorization import Capability, user_has_capability from app.core.config import settings, static +from app.core.directory_index_contract import DIRECTORY_POLICY_TOKEN_CLAIM, DIRECTORY_POLICY_TOKEN_CLASS from app.core.exceptions import ConfigurationError from app.core.logging import get_logger from app.db.database import get_session @@ -106,6 +107,8 @@ def _ensure_user_is_current(user: User | None, failure_exception: HTTPException) def _reject_non_browser_token(payload: dict[str, Any], failure_exception: HTTPException) -> None: + if payload.get(DIRECTORY_POLICY_TOKEN_CLAIM) or payload.get("token_class") == DIRECTORY_POLICY_TOKEN_CLASS: + raise failure_exception if payload.get(_COMPANION_URI_TOKEN_CLAIM): raise failure_exception diff --git a/backend/app/core/system_setting_definitions.py b/backend/app/core/system_setting_definitions.py index 9c7142680..c734e868f 100644 --- a/backend/app/core/system_setting_definitions.py +++ b/backend/app/core/system_setting_definitions.py @@ -3,8 +3,24 @@ from dataclasses import dataclass from enum import StrEnum +from app.core.directory_index_contract import DIRECTORY_INDEX_CONTRACT, POLICY_FIELDS + class SystemSettingKey(StrEnum): + DIRECTORY_INDEX_BACKEND_POLICY_REVISION = "directory_index.backend.policy_revision" + DIRECTORY_INDEX_COMPANION_POLICY_REVISION = "directory_index.companion.policy_revision" + DIRECTORY_INDEX_BACKEND_DISK_BUDGET_BYTES = "directory_index.backend.disk_budget_bytes" + DIRECTORY_INDEX_BACKEND_INACTIVE_RETENTION_SECONDS = "directory_index.backend.inactive_retention_seconds" + DIRECTORY_INDEX_BACKEND_MAX_RETAINED_INACTIVE_SOURCES = "directory_index.backend.max_retained_inactive_sources" + DIRECTORY_INDEX_BACKEND_PAGE_CACHE_KIB = "directory_index.backend.page_cache_kib" + DIRECTORY_INDEX_BACKEND_HEALTHY_VERIFICATION_SECONDS = "directory_index.backend.healthy_verification_seconds" + DIRECTORY_INDEX_BACKEND_DEGRADED_VERIFICATION_SECONDS = "directory_index.backend.degraded_verification_seconds" + DIRECTORY_INDEX_COMPANION_DISK_BUDGET_BYTES = "directory_index.companion.disk_budget_bytes" + DIRECTORY_INDEX_COMPANION_INACTIVE_RETENTION_SECONDS = "directory_index.companion.inactive_retention_seconds" + DIRECTORY_INDEX_COMPANION_MAX_RETAINED_INACTIVE_SOURCES = "directory_index.companion.max_retained_inactive_sources" + DIRECTORY_INDEX_COMPANION_PAGE_CACHE_KIB = "directory_index.companion.page_cache_kib" + DIRECTORY_INDEX_COMPANION_HEALTHY_VERIFICATION_SECONDS = "directory_index.companion.healthy_verification_seconds" + DIRECTORY_INDEX_COMPANION_DEGRADED_VERIFICATION_SECONDS = "directory_index.companion.degraded_verification_seconds" TEMPORARY_ARCHIVE_DOWNLOAD_SIZE_BYTES = "archive.temporary_download_size_bytes" SMB_READ_CHUNK_SIZE_BYTES = "smb.read_chunk_size_bytes" SMB_AUTHENTICATION_MODE = "smb.authentication_mode" @@ -36,6 +52,22 @@ class SystemSettingSource(StrEnum): DEFAULT = "default" +class DirectoryIndexPolicyScope(StrEnum): + BACKEND = "backend" + COMPANION = "companion" + + +DIRECTORY_INDEX_SETTING_KEYS = { + scope: {field: SystemSettingKey(f"directory_index.{scope.value}.{field}") for field in POLICY_FIELDS} + for scope in DirectoryIndexPolicyScope +} +DIRECTORY_INDEX_ALL_SETTING_KEYS = frozenset(key for fields in DIRECTORY_INDEX_SETTING_KEYS.values() for key in fields.values()) +DIRECTORY_INDEX_REVISION_KEYS = { + DirectoryIndexPolicyScope.BACKEND: SystemSettingKey.DIRECTORY_INDEX_BACKEND_POLICY_REVISION, + DirectoryIndexPolicyScope.COMPANION: SystemSettingKey.DIRECTORY_INDEX_COMPANION_POLICY_REVISION, +} + + @dataclass(frozen=True) class IntegerSystemSettingDefinition: key: SystemSettingKey @@ -210,3 +242,17 @@ class IntegerSystemSettingDefinition: step=1024 * 1024, ), } + +for policy_scope, policy_keys in DIRECTORY_INDEX_SETTING_KEYS.items(): + for policy_field_name, policy_key in policy_keys.items(): + policy_field = DIRECTORY_INDEX_CONTRACT.policy[policy_field_name] + SYSTEM_SETTING_DEFINITIONS[policy_key] = IntegerSystemSettingDefinition( + key=policy_key, + config_attr=None, + label=policy_field.label, + description=policy_field.description, + default_value=policy_field.default, + min_value=policy_field.minimum, + max_value=policy_field.maximum, + step=policy_field.step, + ) diff --git a/backend/app/db/database.py b/backend/app/db/database.py index c78c1b382..0951d0240 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -91,7 +91,7 @@ def init_db() -> None: from app.models.app_secret import AppSecret # noqa: F401 - Required for table creation from app.models.archive_operation import ArchiveOperation # noqa: F401 - Required for table creation from app.models.audit import AuditEvent # noqa: F401 - Required for table creation - from app.models.companion_uri_token_jti import CompanionUriTokenJti # noqa: F401 - Required for table creation + from app.models.companion_uri_token_jti import CompanionDirectoryPolicyGrant, CompanionUriTokenJti # noqa: F401 - Required for table creation from app.models.download_intent import DownloadIntent # noqa: F401 - Required for table creation from app.models.edit_lock import EditLock # noqa: F401 - Required for table creation from app.models.oidc import ( # noqa: F401 - Required for table creation diff --git a/backend/app/main.py b/backend/app/main.py index 6f4ecb722..ae6d7a780 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -38,7 +38,7 @@ from app.core.config import consume_unsupported_config_settings, settings from app.core.environment import DEV_CORS_ORIGINS, IS_DEVELOPMENT, IS_PRODUCTION from app.core.exceptions import ConfigurationError, SambeeError -from app.core.logging import SensitiveDataLogFilter, configure_uvicorn_loggers, log_error, set_request_id +from app.core.logging import RecoveryLogFormatter, SensitiveDataLogFilter, configure_uvicorn_loggers, log_error, set_request_id from app.core.secrets import generate_admin_password from app.core.security import get_password_hash from app.db.database import DATABASE_FILE_PATH, engine, init_db @@ -87,6 +87,8 @@ def bootstrap_admin_if_pristine(session: Session) -> tuple[User | None, str | No # This allows config.toml to use human-readable strings like "DEBUG" or "ERROR" log_level = getattr(logging, settings.log_level) logging.basicConfig(level=log_level, format=log_format, handlers=handlers) +for handler in handlers: + handler.setFormatter(RecoveryLogFormatter(log_format)) configure_uvicorn_loggers( handlers=handlers, @@ -98,6 +100,7 @@ def bootstrap_admin_if_pristine(session: Session) -> tuple[User | None, str | No # Reduce noise from third-party libraries (only show warnings/errors) logging.getLogger("smbprotocol").setLevel(logging.WARNING) +logging.getLogger("smbclient").setLevel(logging.WARNING) logging.getLogger("sqlalchemy.engine.Engine").setLevel(logging.WARNING) logging.getLogger("pyvips").setLevel(logging.WARNING) diff --git a/backend/app/models/companion_uri_token_jti.py b/backend/app/models/companion_uri_token_jti.py index f43acad99..d0f522cd5 100644 --- a/backend/app/models/companion_uri_token_jti.py +++ b/backend/app/models/companion_uri_token_jti.py @@ -16,3 +16,20 @@ class CompanionUriTokenJti(SQLModel, table=True): path: str = Field(index=True) expires_at: datetime = Field(index=True) consumed_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), index=True) + + +class CompanionDirectoryPolicyGrant(SQLModel, table=True): + """Bounded, expiring registry of separately scoped administration grants.""" + + __tablename__: ClassVar[str] = "companion_directory_policy_grant" + + jti: uuid.UUID = Field(primary_key=True) + user_id: uuid.UUID = Field(index=True) + companion_id: uuid.UUID + challenge: str + origin: str + backend_identity: str + revision: int + token_version: int + expires_at: datetime = Field(index=True) + consumed_at: datetime | None = None diff --git a/backend/app/models/file.py b/backend/app/models/file.py index 01ac3ad59..51db68920 100644 --- a/backend/app/models/file.py +++ b/backend/app/models/file.py @@ -147,6 +147,15 @@ class DirectorySearchResult(BaseModel): results: List[str] match_ranks: dict[str, int] = Field(default_factory=dict) + matched_term_counts: dict[str, int] = Field(default_factory=dict) total_matches: int cache_state: str directory_count: int + search_contract_version: Literal[1] | None = None + accepted_revision: int | None = Field(default=None, ge=0) + coverage: str | None = None + coverage_issue_count: int = Field(default=0, ge=0) + last_verified_at_ms: int | None = Field(default=None, ge=0) + status_message: str | None = None + monitoring_state: Literal["starting", "healthy", "recovering", "unavailable"] | None = None + retry_after_ms: int | None = Field(default=None, ge=0) diff --git a/backend/app/models/recent_directory.py b/backend/app/models/recent_directory.py index b49a9cee8..07f341e2f 100644 --- a/backend/app/models/recent_directory.py +++ b/backend/app/models/recent_directory.py @@ -39,11 +39,13 @@ class RecentDirectoryRead(SQLModel): last_visited_at: datetime usage_score: float = 0 match_rank: int = 3 + matched_term_count: int = 0 class RecentDirectorySearchRead(SQLModel): results: list[RecentDirectoryRead] result_limit: int + search_contract_version: int = 1 class RecentDirectoryClearRead(SQLModel): diff --git a/backend/app/models/recent_file.py b/backend/app/models/recent_file.py index d3e3d5339..854562429 100644 --- a/backend/app/models/recent_file.py +++ b/backend/app/models/recent_file.py @@ -41,11 +41,13 @@ class RecentFileRead(SQLModel): last_opened_at: datetime usage_score: float = 0 match_rank: int = 3 + matched_term_count: int = 0 class RecentFileSearchRead(SQLModel): results: list[RecentFileRead] result_limit: int + search_contract_version: int = 1 class RecentFileClearRead(SQLModel): diff --git a/backend/app/models/system_settings.py b/backend/app/models/system_settings.py index bec94e5b2..e2841e438 100644 --- a/backend/app/models/system_settings.py +++ b/backend/app/models/system_settings.py @@ -1,15 +1,31 @@ from __future__ import annotations +import ipaddress import uuid from datetime import datetime, timezone from enum import StrEnum from typing import Annotated, Literal, Optional -from pydantic import field_validator, model_validator +from pydantic import StringConstraints, field_validator, model_validator from sqlmodel import Field, SQLModel from sqlmodel._compat import SQLModelConfig -from app.core.system_setting_definitions import SystemSettingKey, SystemSettingSource +from app.core.directory_index_contract import ( + CONTRACT_VERSION, + DIRECTORY_INDEX_CONTRACT, + MAX_DIRECTORY_POLICY_CHALLENGE_BYTES, + MAX_DIRECTORY_POLICY_ORIGIN_BYTES, + MAX_DIRECTORY_POLICY_TOKEN_BYTES, + MAX_WIRE_INTEGER, + MIN_DIRECTORY_POLICY_CHALLENGE_BYTES, + POLICY_FIELDS, +) +from app.core.system_setting_definitions import ( + DIRECTORY_INDEX_ALL_SETTING_KEYS, + DirectoryIndexPolicyScope, + SystemSettingKey, + SystemSettingSource, +) class SystemSetting(SQLModel, table=True): @@ -86,6 +102,83 @@ class StrictSettingUpdate(SQLModel): model_config = SQLModelConfig(extra="forbid") +class DirectoryIndexPolicy(StrictSettingUpdate): + version: Literal[1] = CONTRACT_VERSION + disk_budget_bytes: int = Field( + default=DIRECTORY_INDEX_CONTRACT.policy["disk_budget_bytes"].default, + ge=DIRECTORY_INDEX_CONTRACT.policy["disk_budget_bytes"].minimum, + le=DIRECTORY_INDEX_CONTRACT.policy["disk_budget_bytes"].maximum, + ) + inactive_retention_seconds: int = Field( + default=DIRECTORY_INDEX_CONTRACT.policy["inactive_retention_seconds"].default, + ge=DIRECTORY_INDEX_CONTRACT.policy["inactive_retention_seconds"].minimum, + le=DIRECTORY_INDEX_CONTRACT.policy["inactive_retention_seconds"].maximum, + ) + max_retained_inactive_sources: int = Field( + default=DIRECTORY_INDEX_CONTRACT.policy["max_retained_inactive_sources"].default, + ge=DIRECTORY_INDEX_CONTRACT.policy["max_retained_inactive_sources"].minimum, + le=DIRECTORY_INDEX_CONTRACT.policy["max_retained_inactive_sources"].maximum, + ) + page_cache_kib: int = Field( + default=DIRECTORY_INDEX_CONTRACT.policy["page_cache_kib"].default, + ge=DIRECTORY_INDEX_CONTRACT.policy["page_cache_kib"].minimum, + le=DIRECTORY_INDEX_CONTRACT.policy["page_cache_kib"].maximum, + ) + healthy_verification_seconds: int = Field( + default=DIRECTORY_INDEX_CONTRACT.policy["healthy_verification_seconds"].default, + ge=DIRECTORY_INDEX_CONTRACT.policy["healthy_verification_seconds"].minimum, + le=DIRECTORY_INDEX_CONTRACT.policy["healthy_verification_seconds"].maximum, + ) + degraded_verification_seconds: int = Field( + default=DIRECTORY_INDEX_CONTRACT.policy["degraded_verification_seconds"].default, + ge=DIRECTORY_INDEX_CONTRACT.policy["degraded_verification_seconds"].minimum, + le=DIRECTORY_INDEX_CONTRACT.policy["degraded_verification_seconds"].maximum, + ) + + @field_validator("version", *POLICY_FIELDS, mode="before") + @classmethod + def validate_integer_value(cls, value: object) -> int: + if type(value) is not int: + raise ValueError("Directory-index policy values must be integers, not booleans, text, or fractions.") + return value + + @model_validator(mode="after") + def validate_verification_intervals(self) -> "DirectoryIndexPolicy": + if self.degraded_verification_seconds > self.healthy_verification_seconds: + raise ValueError("Degraded verification must not exceed the healthy verification interval.") + return self + + +class DirectoryIndexSettingsRead(SQLModel): + scope: DirectoryIndexPolicyScope + policy: DirectoryIndexPolicy + backend_identity: str = Field(min_length=64, max_length=64) + revision: int = Field(ge=0, le=MAX_WIRE_INTEGER) + fields: dict[str, IntegerSystemSettingRead] + + +class DirectoryIndexSettingUpdate(StrictSettingUpdate): + field: Literal[ + "disk_budget_bytes", + "inactive_retention_seconds", + "max_retained_inactive_sources", + "page_cache_kib", + "healthy_verification_seconds", + "degraded_verification_seconds", + ] + value: int + + @field_validator("value", mode="before") + @classmethod + def validate_integer_value(cls, value: object) -> int: + return DirectoryIndexPolicy.validate_integer_value(value) + + @model_validator(mode="after") + def validate_supported_value(self) -> "DirectoryIndexSettingUpdate": + DIRECTORY_INDEX_CONTRACT.policy[self.field].validate(self.value) + return self + + class AdvancedSystemSettingUpdate(StrictSettingUpdate): field: SystemSettingKey value: int @@ -94,7 +187,11 @@ class AdvancedSystemSettingUpdate(StrictSettingUpdate): def validate_supported_field(self) -> "AdvancedSystemSettingUpdate": from app.core.system_setting_definitions import SYSTEM_SETTING_DEFINITIONS - if self.field not in SYSTEM_SETTING_DEFINITIONS or self.field is SystemSettingKey.SMB_READ_CHUNK_SIZE_BYTES: + if ( + self.field not in SYSTEM_SETTING_DEFINITIONS + or self.field is SystemSettingKey.SMB_READ_CHUNK_SIZE_BYTES + or self.field in DIRECTORY_INDEX_ALL_SETTING_KEYS + ): raise ValueError("Field is not an editable advanced system setting") return self @@ -197,6 +294,62 @@ def validate_serialized_size(self) -> "FileSearchSettings": return self +class DirectoryPolicyGrantBinding(StrictSettingUpdate): + """The native challenge and consent origin, never a browser policy payload.""" + + companion_id: uuid.UUID + challenge: Annotated[ + str, + StringConstraints( + min_length=MIN_DIRECTORY_POLICY_CHALLENGE_BYTES, max_length=MAX_DIRECTORY_POLICY_CHALLENGE_BYTES, pattern=r"^[A-Za-z0-9_-]+$" + ), + ] + origin: str = Field(max_length=MAX_DIRECTORY_POLICY_ORIGIN_BYTES) + + @field_validator("origin") + @classmethod + def validate_origin(cls, value: str) -> str: + from pydantic import HttpUrl + + parsed = HttpUrl(value) + host = (parsed.host or "").strip("[]") + try: + loopback = ipaddress.ip_address(host).is_loopback + except ValueError: + loopback = host.lower() == "localhost" + if parsed.scheme != "https" and not (parsed.scheme == "http" and loopback): + raise ValueError("Policy origin must use HTTPS, or HTTP on a loopback host.") + if parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in (None, "/"): + raise ValueError("Policy origin must contain only the scheme, host and optional port.") + canonical = str(parsed).rstrip("/") + for origin in (value, canonical): + try: + encoded = origin.encode("utf-8") + except UnicodeEncodeError: + raise ValueError("Policy origin must contain valid Unicode text.") from None + if len(encoded) > MAX_DIRECTORY_POLICY_ORIGIN_BYTES: + raise ValueError(f"Policy origin must contain at most {MAX_DIRECTORY_POLICY_ORIGIN_BYTES} UTF-8 bytes.") + return canonical + + +class DirectoryPolicyGrantRedeem(DirectoryPolicyGrantBinding): + grant: str = Field(min_length=1, max_length=MAX_DIRECTORY_POLICY_TOKEN_BYTES) + + +class DirectoryPolicyGrantRead(StrictSettingUpdate): + grant: str + expires_in_seconds: int + revision: int + backend_identity: str + + +class DirectoryPolicyGrantReceipt(DirectoryPolicyGrantBinding): + version: Literal[1] = 1 + backend_identity: str + revision: int = Field(ge=0) + policy: DirectoryIndexPolicy + + class FileSearchSettingsRead(SQLModel): settings: FileSearchSettings source: SystemSettingSource diff --git a/backend/app/services/directory_cache.py b/backend/app/services/directory_cache.py index 3ea74b3db..87f94359f 100644 --- a/backend/app/services/directory_cache.py +++ b/backend/app/services/directory_cache.py @@ -1,41 +1,20 @@ -""" -Directory cache service for instant directory navigation. - -Maintains an in-memory cache of all directory paths per SMB connection, -enabling instant substring search across the entire directory tree. - -Architecture: -- One cache per connection (not per user) -- BFS scan using smbclient.scandir() via the connection pool -- CHANGE_NOTIFY watcher on share root for live updates (DIR_NAME only) -- Periodic full rescan as safety net (default: 60 min) -- Stale serving during rescans (eventual consistency) -- JSONL snapshot persistence for instant availability on restart - -Persistence: -- Snapshots are written to the configured location (default: data//) -- Format: JSONL — header line with version/timestamp/count, then one {"p":"path"} per directory -- Write-behind with dirty flag + coalesced flush (configurable interval, default 30s) -- Atomic writes via temp file + os.replace(); previous file kept as .bak backup -- Snapshots older than the configured max staleness (default 30 days) are ignored -- On startup, a valid snapshot is loaded instantly, then a background rescan verifies freshness - -See documentation_developers/INSTANT_DIRECTORY_NAVIGATION.md for full design. -""" +"""SQLite directory catalogues with shared SMB observation and verification workers.""" import asyncio +import hashlib import json import logging -import os -import tempfile import threading import time +from abc import ABC, abstractmethod +from collections import deque +from contextlib import contextmanager from dataclasses import dataclass from enum import Enum from pathlib import Path -from typing import Optional +from typing import Any, Callable, Iterable, Iterator, Optional, TypeVar +from uuid import uuid4 -import smbclient from smbclient._os import FileAttributes from smbprotocol.change_notify import ( ChangeNotifyFlags, @@ -44,6 +23,8 @@ FileSystemWatcher, ) from smbprotocol.connection import Connection +from smbprotocol.exceptions import NotifyEnumDir, SMBOSError, SMBResponseException +from smbprotocol.header import NtStatus from smbprotocol.open import ( CreateDisposition, CreateOptions, @@ -55,14 +36,26 @@ from smbprotocol.open import ( FileAttributes as SMBFileAttributes, ) -from smbprotocol.reparse_point import ReparseTags from smbprotocol.session import Session from smbprotocol.tree import TreeConnect from app.core.config import settings, static -from app.services.history_common import directory_match_rank, normalize_history_search_text -from app.services.system_settings import get_smbclient_policy_kwargs +from app.core.directory_index_contract import DIRECTORY_INDEX_CONTRACT +from app.models.system_settings import DirectoryIndexPolicy +from app.services.directory_index import ( + MAX_SIGNED_INTEGER, + QUERY_WORKERS, + ROW_BATCH_SIZE, + SQL_BUSY_TIMEOUT_MS, + DirectoryIndex, + DirectoryIndexSearch, + validate_directory_path, +) +from app.services.directory_index_resources import DirectoryDiskBudgetError, DirectoryIndexResources +from app.services.directory_traversal import DirectoryBindingChangedError, DirectoryTraversalError, SmbDirectoryRead, SmbDirectoryRoot +from app.services.system_settings import get_directory_index_policy, get_smbclient_policy_kwargs from app.storage.smb_pool import SMBConnectionPool, get_connection_pool, get_smb_connection_cache +from app.utils.worker_wait import wait_for_worker logger = logging.getLogger(__name__) @@ -83,13 +76,10 @@ class CacheState(str, Enum): # Scan configuration -SCAN_BATCH_SIZE = 50 # Directories to process per BFS batch SCAN_TIMEOUT_SECONDS = 30.0 # Timeout for single scandir call -RESCAN_INTERVAL_SECONDS = 3600 # 60 minutes between periodic rescans # CHANGE_NOTIFY configuration CHANGE_NOTIFY_BUFFER_SIZE = 65536 # 64 KB — protocol maximum -CHANGE_NOTIFY_DEBOUNCE_SECONDS = 2 # Aggregate events before processing # Reconnect configuration MAX_WATCHER_RETRIES = 5 @@ -99,31 +89,24 @@ class CacheState(str, Enum): # Search configuration MAX_SEARCH_RESULTS = 200 PATH_SEPARATOR = "/" -HIDDEN_DIRECTORY_PREFIX = "." # Persistence configuration CACHE_PERSIST_SUBDIR = "directory_cache" # Default subdirectory under data_dir -CACHE_FILE_EXTENSION = ".idx" -CACHE_BACKUP_EXTENSION = ".bak" # Backup kept alongside the main file -CACHE_FILE_VERSION = 2 - - -def path_contains_dot_directory(path: str) -> bool: - """Return True when any path segment is dot-prefixed.""" - - return any(segment.startswith(HIDDEN_DIRECTORY_PREFIX) for segment in path.split(PATH_SEPARATOR) if segment) - - -@dataclass(frozen=True) -class _ScannedDirectory: - path: str - is_reparse_point: bool = False - is_dfs_referral: bool = False - - @property - def can_descend(self) -> bool: - """Follow DFS referrals, but do not follow arbitrary directory links.""" - return not self.is_reparse_point or self.is_dfs_referral +MAX_PENDING_OBSERVATIONS = DIRECTORY_INDEX_CONTRACT.limits["max_pending_observations"] +MAX_OBSERVATION_BYTES = DIRECTORY_INDEX_CONTRACT.limits["max_observation_bytes"] +SQLITE_CACHE_SUBDIR = "sqlite" +SQLITE_CACHE_EXTENSION = ".sqlite3" +DEGRADED_VERIFICATION_SECONDS = DIRECTORY_INDEX_CONTRACT.policy["degraded_verification_seconds"].default +HEALTHY_VERIFICATION_SECONDS = DIRECTORY_INDEX_CONTRACT.policy["healthy_verification_seconds"].default +OBSERVATION_POLL_SECONDS = DIRECTORY_INDEX_CONTRACT.limits["event_coalescing_ms"] / 1000 +OBSERVATION_RETRY_SECONDS = 1.0 +WORKER_POLL_SECONDS = SQL_BUSY_TIMEOUT_MS / 1000 +FAILURE_RETRY_MIN_SECONDS = 30.0 +FAILURE_RETRY_MAX_SECONDS = 300.0 +FAILURE_RETRY_JITTER_PERCENT = 10 +PERIODIC_VERIFICATION_JITTER_PERCENT = 10 +READ_PARENT_ACTION = -1 +WorkerResult = TypeVar("WorkerResult") @dataclass(frozen=True) @@ -144,23 +127,231 @@ def invalidates_read(self, directory: str) -> bool: if path is not None ) - def apply(self, directories: set[str]) -> set[str]: - """Apply the same path operation to a live index or a rescan snapshot.""" - if self.action == FileAction.FILE_ACTION_ADDED: - directories.add(self.path) - return directories +@dataclass(frozen=True) +class ScanReadToken: + epoch: int + sequence: int + path: str + producer_id: int - prefix = self.path + PATH_SEPARATOR - if self.action == FileAction.FILE_ACTION_REMOVED: - return {path for path in directories if path != self.path and not path.startswith(prefix)} - if self.action == FileAction.FILE_ACTION_RENAMED_NEW_NAME and self.new_path: - return { - self.new_path + path[len(self.path) :] if path == self.path or path.startswith(prefix) else path for path in directories - } +class ScanObservationInvalidated(RuntimeError): + """A filesystem observation can no longer be safely committed.""" + + +class DirectoryScanCoordinator: + """Serialize bounded observations and reject stale filesystem batches.""" - raise ValueError(f"Invalid directory cache mutation: action={self.action}, path={self.path!r}") + def __init__( + self, index: DirectoryIndex, *, maximum_pending: int = MAX_PENDING_OBSERVATIONS, maximum_bytes: int = MAX_OBSERVATION_BYTES + ) -> None: + if maximum_pending < 1 or maximum_bytes < 1: + raise ValueError("Directory observation limits must be positive.") + self.index = index + self.maximum_pending = maximum_pending + self.maximum_bytes = maximum_bytes + self.lock = threading.RLock() + self.pending: deque[tuple[int, _DirectoryMutation, int]] = deque() + self.barriers: deque[tuple[int, str, int]] = deque() + self.pending_bytes = 0 + self.barrier_bytes = 0 + self.sequence = 0 + self.epoch = 0 + self.verification_required = True + self._uncertain_pending = False + self._next_producer_id = 0 + self._producers: dict[int, ScanReadToken] = {} + + def invalidate(self) -> None: + with self.lock: + self.epoch += 1 + self.barriers.clear() + self.barrier_bytes = 0 + self.verification_required = True + self._uncertain_pending = True + + def enqueue(self, mutation: _DirectoryMutation) -> bool: + if mutation.path or mutation.action != READ_PARENT_ACTION: + validate_directory_path(mutation.path) + if mutation.new_path is not None: + validate_directory_path(mutation.new_path) + paths = (mutation.path,) if mutation.new_path is None else (mutation.path, mutation.new_path) + size = sum(len(path.encode("utf-8")) for path in paths) + with self.lock: + if self.sequence >= MAX_SIGNED_INTEGER: + self.invalidate() + raise OverflowError("Directory observation sequence is exhausted. Reinitialize this source.") + self.sequence += 1 + if len(self.pending) >= self.maximum_pending or self.pending_bytes + size > self.maximum_bytes: + self.invalidate() + return False + self.pending.append((self.sequence, mutation, size)) + self.pending_bytes += size + self.verification_required = True + for path in paths: + self._record_barrier(path) + return True + + def _record_barrier(self, path: str) -> None: + """Merge overlapping prefixes without allowing an older read to publish.""" + retained: deque[tuple[int, str, int]] = deque() + prefix = path + for sequence, existing, size in self.barriers: + if not existing or path == existing or path.startswith(existing + PATH_SEPARATOR): + prefix = existing + elif path and not existing.startswith(path + PATH_SEPARATOR): + retained.append((sequence, existing, size)) + size = len(prefix.encode("utf-8")) + total_bytes = sum(entry[2] for entry in retained) + size + if len(retained) >= self.maximum_pending or total_bytes > self.maximum_bytes: + self.invalidate() + return + retained.append((self.sequence, prefix, size)) + self.barriers = retained + self.barrier_bytes = total_bytes + + def begin_read(self, path: str) -> ScanReadToken: + if path: + validate_directory_path(path) + with self.lock: + if len(self._producers) >= MAX_PENDING_OBSERVATIONS or self._next_producer_id >= MAX_SIGNED_INTEGER: + self.invalidate() + raise ScanObservationInvalidated("Directory read capacity is exhausted. Wait for active source reads to finish.") + self._next_producer_id += 1 + token = ScanReadToken(self.epoch, self.sequence, path, self._next_producer_id) + self._producers[token.producer_id] = token + return token + + def end_read(self, token: ScanReadToken) -> None: + """Release a producer only after its reads and synchronous batches have exited.""" + with self.lock: + if self._producers.get(token.producer_id) == token: + del self._producers[token.producer_id] + self._retire_barriers() + + @contextmanager + def read(self, path: str) -> Iterator[ScanReadToken]: + token = self.begin_read(path) + try: + yield token + finally: + self.end_read(token) + + def _retire_barriers(self) -> None: + """Pending mutations and older live reads retain their rejection fences.""" + oldest = min( + (token.sequence for token in self._producers.values() if token.epoch == self.epoch), + default=self.sequence, + ) + if self.pending: + oldest = min(oldest, self.pending[0][0] - 1) + self.barriers = deque(entry for entry in self.barriers if entry[0] > oldest) + self.barrier_bytes = sum(entry[2] for entry in self.barriers) + + def is_current(self, token: ScanReadToken) -> bool: + with self.lock: + return ( + self._producers.get(token.producer_id) == token + and token.epoch == self.epoch + and not any( + sequence > token.sequence + and ( + not path + or not token.path + or path == token.path + or path.startswith(token.path + PATH_SEPARATOR) + or token.path.startswith(path + PATH_SEPARATOR) + ) + for sequence, path, _size in self.barriers + ) + ) + + def apply_pending(self) -> int: + with self.lock: + if self._uncertain_pending: + self.index.mark_uncertain() + self._uncertain_pending = False + applied = 0 + while self.pending and applied < ROW_BATCH_SIZE: + _sequence, mutation, size = self.pending[0] + if mutation.action == READ_PARENT_ACTION: + self.index.queue_observed_parent(mutation.path) + elif mutation.action == FileAction.FILE_ACTION_ADDED: + self.index.mutate_observed_path("add", mutation.path) + elif mutation.action == FileAction.FILE_ACTION_REMOVED: + self.index.mutate_observed_path("remove", mutation.path) + elif mutation.action == FileAction.FILE_ACTION_RENAMED_NEW_NAME: + self.index.mutate_observed_path("rename", mutation.path, mutation.new_path) + else: + self.invalidate() + raise ValueError("Directory observation action is unsupported. Request source verification.") + self.pending.popleft() + self.pending_bytes -= size + applied += 1 + self._retire_barriers() + return applied + + def commit_children( + self, token: ScanReadToken, generation_id: int, children: Iterable[tuple[str, bool]], *, reconcile: bool = False + ) -> None: + with self.lock: + if not self.is_current(token): + self.verification_required = True + raise ScanObservationInvalidated("Directory scan observation became stale. Restart source verification.") + self.apply_pending() + if reconcile: + self.index.write_reconciliation_children(generation_id, token.path, children) + else: + self.index.write_scan_children(generation_id, children) + + def record_unsafe_traversal(self, token: ScanReadToken, generation_id: int) -> None: + self._record_scan_issue(token, generation_id, self.index.record_unsafe_traversal) + + def record_access_denied(self, token: ScanReadToken, generation_id: int) -> None: + self._record_scan_issue(token, generation_id, self.index.record_access_denied) + + def _record_scan_issue(self, token: ScanReadToken, generation_id: int, record: Callable[[int], None]) -> None: + with self.lock: + if not self.is_current(token): + self.verification_required = True + raise ScanObservationInvalidated("Directory scan observation became stale. Restart source verification.") + self.apply_pending() + record(generation_id) + + def finish_parent(self, token: ScanReadToken, generation_id: int, *, reconcile: bool = False) -> None: + with self.lock: + if not self.is_current(token): + self.verification_required = True + raise ScanObservationInvalidated("Directory scan observation became stale. Restart source verification.") + self.apply_pending() + if reconcile: + self.index.finish_reconciliation_parent(generation_id, token.path) + else: + self.index.finish_scan_parent(generation_id, token.path) + + def accept_generation(self, token: ScanReadToken, generation_id: int, *, issue_count: int = 0) -> bool: + with self.lock: + if ( + token.path + or self._producers.get(token.producer_id) != token + or token.epoch != self.epoch + or self._uncertain_pending + or self.pending + or any(producer != token and producer.epoch == self.epoch for producer in self._producers.values()) + ): + self.verification_required = True + raise ScanObservationInvalidated("Directory scan capture is not current. Drain source observations and retry verification.") + if self.index.reconciliation_generation() is not None or self.index.pending_scan_paths(generation_id, limit=1): + self.verification_required = True + raise ScanObservationInvalidated( + "Directory parent work remains pending. Complete reconciliation before source verification." + ) + accepted = self.index.finish_generation(generation_id, issue_count=issue_count) + self.verification_required = not accepted + if accepted: + self._uncertain_pending = False + return accepted # ============================================================================ @@ -168,17 +359,8 @@ def apply(self, directories: set[str]) -> set[str]: # ============================================================================ -class ConnectionDirectoryCache: - """ - In-memory cache of all directory paths for a single SMB connection. - - Lifecycle: - 1. Created when first needed (user opens a connection) - 2. BFS scans all directories from share root - 3. CHANGE_NOTIFY watcher keeps cache updated - 4. Periodic rescan as safety net - 5. Destroyed when DirectoryCacheManager shuts down - """ +class ConnectionDirectoryCache(ABC): + """Shared SMB binding, watcher, and task lifecycle for a directory catalogue.""" # # __init__ @@ -204,7 +386,7 @@ def __init__( password: SMB password port: SMB port (default 445) path_prefix: Base path within the share (e.g. "/photos") - source_revision: Connection revision used to invalidate snapshots after edits + source_revision: Connection revision used to invalidate source bindings after edits """ self.connection_id = connection_id @@ -213,18 +395,25 @@ def __init__( self.username = username self.password = password self.port = port + self._source_revision = source_revision self._connection_cache = get_smb_connection_cache(connection_id) + self._diagnostic_generation = uuid4().hex + self._log_context = { + "source_id": hashlib.sha256(connection_id.encode()).hexdigest()[:16], + "generation_id": self._diagnostic_generation, + "operation": "directory_index", + } # Normalize path_prefix: strip slashes so "" means share root cleaned = (path_prefix or "/").replace("\\", "/").strip("/") self._path_prefix = cleaned # e.g. "photos" or "" - self._source_identity = { + self._source_identity: dict[str, str | int] = { + "source_kind": "smb", "host": host, "share_name": share_name, "port": port, "path_prefix": cleaned, "username": username, - "revision": source_revision, } # Pre-compute the SMB base path (share root + prefix) share_base = f"\\\\{host}\\{share_name}" @@ -233,12 +422,6 @@ def __init__( else: self._smb_base_path = share_base - # The cache: a set of all directory paths (relative to share root) - # Root is represented as "" - self._directories: set[str] = set() - self._lock = threading.Lock() - self._rescan_changes: Optional[list[_DirectoryMutation]] = None - # State tracking self._state = CacheState.EMPTY self._directory_count = 0 @@ -248,7 +431,6 @@ def __init__( # Background tasks self._scan_task: Optional[asyncio.Task[None]] = None self._rescan_task: Optional[asyncio.Task[None]] = None - self._verification_task: Optional[asyncio.Task[None]] = None self._watcher_thread: Optional[threading.Thread] = None self._stop_event = threading.Event() @@ -257,11 +439,11 @@ def __init__( self._watcher_session: Optional[Session] = None self._watcher_tree: Optional[TreeConnect] = None self._watcher_open: Optional[Open] = None + self._watcher_received_events = False + self._watcher_ready = threading.Event() + self._watcher_retry_at = 0.0 - # Disk persistence - self._dirty = False - self._coalesce_task: Optional[asyncio.Task[None]] = None - self._persist_path: Path = self._resolve_persist_path(connection_id) + self._cache_root = self._resolve_cache_root() @property def state(self) -> CacheState: @@ -269,9 +451,13 @@ def state(self) -> CacheState: return self._state + @property + def _logger(self) -> logging.LoggerAdapter[logging.Logger]: + return logging.LoggerAdapter(logger, self._log_context, merge_extra=True) + @staticmethod - def _resolve_persist_path(connection_id: str) -> Path: - """Compute the on-disk path for a connection's index file. + def _resolve_cache_root() -> Path: + """Resolve the configured parent of the private SQLite namespace. Uses ``settings.directory_cache_location`` when set (relative paths are resolved against *data_dir*); otherwise falls back to @@ -286,7 +472,7 @@ def _resolve_persist_path(connection_id: str) -> Path: else: base = static.data_dir / CACHE_PERSIST_SUBDIR - return base / f"{connection_id}{CACHE_FILE_EXTENSION}" + return base @property def directory_count(self) -> int: @@ -313,54 +499,40 @@ def scan_error(self) -> Optional[str]: # # search # - def search( - self, - query: str, - max_results: int = MAX_SEARCH_RESULTS, - *, - include_dot_directories: bool = False, - ) -> tuple[list[str], int]: - """Search for directories matching query (case-insensitive substring). + @abstractmethod + def search(self, query: str, max_results: int = MAX_SEARCH_RESULTS, *, include_dot_directories: bool = False) -> tuple[list[str], int]: + """Query the selected catalogue without traversing the source.""" + raise NotImplementedError - Supports path separators in the query: both ``/`` and ``\\`` are - normalised to ``/`` so that queries like ``abc/def`` or ``abc\\def`` - match across directory levels. - - Args: - query: Search term (substring match, may contain path separators) - max_results: Maximum results to return (server-side cap) - include_dot_directories: Whether to include dot-prefixed path segments - - Returns: - Tuple of (matching directory paths sorted by relevance, total match count). - The list is capped at max_results; total_count reflects all matches - before capping, so callers can detect truncation. - """ - - if not query: - return [], 0 + @abstractmethod + def _apply_mutations(self, mutations: list[_DirectoryMutation]) -> None: + """Queue bounded source observations for the owning writer.""" + raise NotImplementedError - # Normalise backslashes to forward slashes so users can type either - query_lower = normalize_history_search_text(query.replace("\\", "/").strip()) + @abstractmethod + async def start_scan(self) -> None: + """Start or join background catalogue initialization.""" + raise NotImplementedError - with self._lock: - # Substring match on all cached paths - matches = [ - directory - for directory in self._directories - if query_lower in normalize_history_search_text(directory) - and (include_dot_directories or not path_contains_dot_directory(directory)) - ] + @abstractmethod + async def _run_rescan(self) -> None: + """Verify the source while preserving the selected catalogue.""" + raise NotImplementedError - def sort_key(path: str) -> tuple[int, int, str]: - """Sorting key: (priority_tier, path_depth, path_string).""" + @abstractmethod + def _process_change_events(self, events: list[dict[str, Any]]) -> None: + """Route native hints without inferring unverified directory rows.""" + raise NotImplementedError - depth = path.count("/") - return (directory_match_rank(path, query_lower), depth, path.lower()) + @abstractmethod + def _observation_lost(self) -> None: + """Invalidate live reads and retain a verification request.""" + raise NotImplementedError - matches.sort(key=sort_key) - total_count = len(matches) - return matches[:max_results], total_count + @abstractmethod + def _finish_stop(self) -> None: + """Retain source and storage ownership until actual worker exit.""" + raise NotImplementedError # # add_directory @@ -432,591 +604,6 @@ def rename_directory(self, old_path: str, new_path: str) -> None: self._apply_mutations([_DirectoryMutation(FileAction.FILE_ACTION_RENAMED_NEW_NAME, old_normalized, new_normalized)]) - def _apply_mutations(self, mutations: list[_DirectoryMutation]) -> None: - """Update the live index and retain changes for an active rescan.""" - - with self._lock: - for mutation in mutations: - self._directories = mutation.apply(self._directories) - if self._rescan_changes is not None: - self._rescan_changes.extend(mutations) - self._directory_count = len(self._directories) - self._dirty = True - - # ======================================================================== - # Disk persistence - # ======================================================================== - - # - # save_to_disk - # - def save_to_disk(self) -> None: - """Persist the current directory set to disk as JSONL. - - Uses atomic write (temp file + rename) so readers never see a - half-written file. Before replacing the main file, the current - version is kept as a ``.bak`` backup so a valid snapshot always - exists even if the process is killed mid-write. - - File format (JSONL): - Line 1 (header): version, connection ID, source identity, timestamp, count - Lines 2+: {"p":"relative/dir/path"} - """ - - with self._lock: - dirs = list(self._directories) - self._dirty = False - - backup_path = self._persist_path.with_suffix(CACHE_BACKUP_EXTENSION) - - try: - self._persist_path.parent.mkdir(parents=True, exist_ok=True) - - fd, tmp_path = tempfile.mkstemp( - dir=str(self._persist_path.parent), - prefix=f".{self.connection_id[:8]}_", - suffix=".tmp", - ) - try: - with os.fdopen(fd, "w", encoding="utf-8") as f: - header = { - "v": CACHE_FILE_VERSION, - "connection_id": self.connection_id, - "source": self._source_identity, - "ts": time.time(), - "count": len(dirs), - } - f.write(json.dumps(header, separators=(",", ":")) + "\n") - - for d in dirs: - f.write(json.dumps({"p": d}, separators=(",", ":")) + "\n") - - # Rotate: current -> backup, then temp -> current - if self._persist_path.exists(): - try: - os.replace(str(self._persist_path), str(backup_path)) - except OSError as e: - logger.debug(f"Could not create backup for connection {self.connection_id}: {e}") - - os.replace(tmp_path, str(self._persist_path)) - logger.debug(f"Saved directory cache to disk for connection {self.connection_id}: {len(dirs)} directories") - except BaseException: - # Clean up temp file on any error - try: - os.unlink(tmp_path) - except OSError: - pass - raise - - except Exception as e: - logger.warning(f"Failed to save directory cache to disk for connection {self.connection_id}: {type(e).__name__}: {e}") - - # - # load_from_disk - # - def load_from_disk(self) -> bool: - """Load the directory set from a JSONL snapshot on disk. - - Tries the main index file first; if it is missing, corrupt, or stale, - falls back to the ``.bak`` backup file. - - Returns True if a valid, non-stale snapshot was loaded. - """ - - backup_path = self._persist_path.with_suffix(CACHE_BACKUP_EXTENSION) - - # Try main file first, then backup - for path, label in [(self._persist_path, "index"), (backup_path, "backup")]: - if not path.exists(): - continue - - result = self._load_snapshot_file(path, label) - if result: - return True - - return False - - # - # _load_snapshot_file - # - def _load_snapshot_file(self, path: Path, label: str) -> bool: - """Attempt to load a single JSONL snapshot file. - - Returns True if valid and non-stale, False otherwise. - - Args: - path: Path to the JSONL file - label: Human-readable label for log messages (e.g. "index", "backup") - """ - - try: - with open(path, encoding="utf-8") as f: - header_line = f.readline() - if not header_line: - logger.warning(f"Empty {label} file for connection {self.connection_id}, ignoring") - return False - - header = json.loads(header_line) - - # Version check - if header.get("v") != CACHE_FILE_VERSION: - logger.info( - f"{label.capitalize()} version mismatch for connection {self.connection_id} " - f"(got {header.get('v')}, expected {CACHE_FILE_VERSION}), ignoring" - ) - return False - - # Connection ID check - if header.get("connection_id") != self.connection_id: - logger.warning(f"{label.capitalize()} connection_id mismatch for connection {self.connection_id}, ignoring") - return False - - if header.get("source") != self._source_identity: - logger.info("Ignoring %s for connection %s: connection target or revision changed", label, self.connection_id) - return False - - # Staleness check - ts = header.get("ts", 0) - age = time.time() - ts - max_staleness_seconds = settings.directory_cache_max_staleness_minutes * 60 - if age > max_staleness_seconds: - logger.info( - f"{label.capitalize()} file for connection {self.connection_id} is stale ({age / 86400:.1f} days old), ignoring" - ) - return False - - expected_count = header.get("count", 0) - - # Read directory entries - dirs: set[str] = set() - for line in f: - line = line.strip() - if not line: - continue - entry = json.loads(line) - dir_path = entry.get("p", "") - if dir_path: - dirs.add(dir_path) - - # Validate count matches - if len(dirs) != expected_count: - logger.warning( - f"{label.capitalize()} count mismatch for connection {self.connection_id}: " - f"header says {expected_count}, found {len(dirs)} entries" - ) - # Still usable — load what we have - - with self._lock: - self._directories = dirs - self._directory_count = len(dirs) - - self._last_scan_time = ts - self._state = CacheState.READY - logger.info( - f"Loaded directory cache from {label} for connection {self.connection_id}: " - f"{len(dirs)} directories (snapshot age: {age:.0f}s)" - ) - return True - - except (json.JSONDecodeError, KeyError, TypeError, ValueError) as e: - logger.warning(f"Corrupt {label} file for connection {self.connection_id}: {type(e).__name__}: {e}") - return False - except Exception as e: - logger.warning(f"Failed to load directory cache from {label} for connection {self.connection_id}: {type(e).__name__}: {e}") - return False - - # - # _start_coalesce_flush - # - def _start_coalesce_flush(self) -> None: - """Start the coalesced flush background task if not already running.""" - - if self._coalesce_task is not None and not self._coalesce_task.done(): - return - - try: - loop = asyncio.get_running_loop() - self._coalesce_task = loop.create_task(self._coalesce_flush_loop()) - except RuntimeError: - # No running event loop — save synchronously - if self._dirty: - self.save_to_disk() - - # - # _coalesce_flush_loop - # - async def _coalesce_flush_loop(self) -> None: - """Periodically flush dirty cache to disk. - - Runs until stopped, writing at most once per the configured - coalesce interval. - """ - - interval = settings.directory_cache_coalesce_interval_seconds - while not self._stop_event.is_set(): - try: - await asyncio.sleep(interval) - if self._stop_event.is_set(): - break - if self._dirty: - self.save_to_disk() - except asyncio.CancelledError: - break - except Exception as e: - logger.warning(f"Error in coalesce flush for connection {self.connection_id}: {type(e).__name__}: {e}") - - # - # delete_persist_file - # - def delete_persist_file(self) -> None: - """Remove the on-disk index and backup files for this connection.""" - - backup_path = self._persist_path.with_suffix(CACHE_BACKUP_EXTENSION) - for path, label in [(self._persist_path, "index"), (backup_path, "backup")]: - try: - if path.exists(): - path.unlink() - logger.debug(f"Deleted {label} file for connection {self.connection_id}") - except Exception as e: - logger.warning(f"Failed to delete {label} file for connection {self.connection_id}: {type(e).__name__}: {e}") - - # ======================================================================== - # Initial scan (BFS) - # ======================================================================== - - # - # start_scan - # - async def start_scan(self) -> None: - """Start the initial directory scan in the background. - - Safe to call multiple times — will not start a new scan if one is - already in progress. - """ - - if self._state in (CacheState.BUILDING, CacheState.UPDATING): - logger.debug(f"Scan already in progress for connection {self.connection_id}") - return - - self._state = CacheState.BUILDING - self._scan_error = None - self._scan_task = asyncio.create_task(self._run_scan()) - - # - # _run_scan - # - async def _run_scan(self) -> None: - """Execute a full BFS scan of the share directory tree. - - Uses smbclient.scandir() via the connection pool for each directory. - Results are added progressively so search works during scan. - """ - - logger.info(f"Starting directory scan for connection {self.connection_id} ({self.host}/{self.share_name})") - - scan_start = time.monotonic() - scanned_count = 0 - error_count = 0 - - try: - pool = await get_connection_pool() - base_path = self._smb_base_path - - async with pool.get_connection( - host=self.host, - port=self.port, - username=self.username, - password=self.password, - share_name=self.share_name, - connection_cache=self._connection_cache, - ): - # BFS queue: list of relative paths to scan - queue: list[str] = [""] - discovered: set[str] = set() - - while queue: - # Process in batches to avoid holding the event loop too long - batch = queue[:SCAN_BATCH_SIZE] - queue = queue[SCAN_BATCH_SIZE:] - - for rel_path in batch: - if self._stop_event.is_set(): - logger.info(f"Scan cancelled for connection {self.connection_id}") - return - - # Build SMB path (relative to the prefix-adjusted base) - if rel_path: - smb_path = f"{base_path}\\{rel_path.replace('/', '\\')}" - else: - smb_path = base_path - - try: - subdirs = await self._scan_directory_with_pool_lease(pool, smb_path, rel_path) - - # Add discovered directories to cache and queue - for subdir in subdirs: - if subdir.path not in discovered: - discovered.add(subdir.path) - self.add_directory(subdir.path) - if subdir.can_descend: - queue.append(subdir.path) - - scanned_count += 1 - except asyncio.TimeoutError: - logger.warning(f"Timeout scanning directory '{rel_path}' for connection {self.connection_id}") - error_count += 1 - except Exception as e: - logger.warning( - f"Error scanning directory '{rel_path}' for connection {self.connection_id}: {type(e).__name__}: {e}" - ) - error_count += 1 - - # Yield to event loop between batches - await asyncio.sleep(0) - - elapsed = time.monotonic() - scan_start - self._last_scan_time = time.time() - self._state = CacheState.READY - logger.info( - f"Directory scan complete for connection {self.connection_id}: " - f"{self._directory_count} directories found, " - f"{scanned_count} dirs scanned, {error_count} errors, " - f"{elapsed:.1f}s elapsed" - ) - - # Start the CHANGE_NOTIFY watcher and periodic rescan - self._start_watcher() - self._start_periodic_rescan() - - # Persist snapshot to disk and start coalesced flushing - self.save_to_disk() - self._start_coalesce_flush() - - except Exception as e: - elapsed = time.monotonic() - scan_start - self._scan_error = f"{type(e).__name__}: {e}" - # Keep whatever we found so far — partial cache is better than nothing - if self._directory_count > 0: - self._state = CacheState.READY - else: - self._state = CacheState.EMPTY - logger.error( - f"Directory scan failed for connection {self.connection_id} " - f"after {elapsed:.1f}s ({self._directory_count} dirs found before failure): " - f"{type(e).__name__}: {e}", - exc_info=True, - ) - - # - # _scan_single_directory - # - def _scan_single_directory(self, smb_path: str, rel_path: str) -> list[_ScannedDirectory]: - """Scan a single directory for subdirectories (runs in executor). - - Args: - smb_path: Full UNC path to the directory - rel_path: Relative path from share root - - Returns: - Relative directory paths and whether each entry is a reparse point - """ - - subdirs: list[_ScannedDirectory] = [] - - try: - entries = smbclient.scandir( - smb_path, - username=self.username, - password=self.password, - port=self.port, - connection_cache=self._connection_cache, - **get_smbclient_policy_kwargs(), - ) - for entry in entries: - if entry.name in (".", ".."): - continue - - try: - info = entry.smb_info - is_dir = bool(info.file_attributes & FileAttributes.FILE_ATTRIBUTE_DIRECTORY) - - if is_dir: - child_path = f"{rel_path}/{entry.name}" if rel_path else entry.name - subdirs.append( - _ScannedDirectory( - child_path, - is_reparse_point=bool(info.file_attributes & FileAttributes.FILE_ATTRIBUTE_REPARSE_POINT), - is_dfs_referral=bool(info.file_attributes & FileAttributes.FILE_ATTRIBUTE_REPARSE_POINT) - and info.ea_size == ReparseTags.IO_REPARSE_TAG_DFS, - ) - ) - except Exception as e: - logger.debug(f"Error processing entry '{entry.name}' in '{rel_path}': {e}") - raise - except Exception as e: - logger.debug(f"Error scanning '{smb_path}': {type(e).__name__}: {e}") - raise - - return subdirs - - async def _scan_directory_with_pool_lease(self, pool: SMBConnectionPool, smb_path: str, rel_path: str) -> list[_ScannedDirectory]: - """Run a blocking scan without releasing its pool lease prematurely.""" - - future = asyncio.get_running_loop().run_in_executor(None, self._scan_single_directory, smb_path, rel_path) - try: - return await asyncio.wait_for(asyncio.shield(future), timeout=SCAN_TIMEOUT_SECONDS) - except (asyncio.TimeoutError, asyncio.CancelledError): - await pool.retain_connection_until_future_complete(self._connection_cache, future) - raise - - # - # _run_rescan - # - async def _run_rescan(self) -> None: - """Execute a periodic rescan, replacing the cache when done. - - During rescan, the old cache continues to serve search requests - (stale serving / eventual consistency). - """ - - with self._lock: - if self._state in (CacheState.BUILDING, CacheState.UPDATING): - return - previous_state = self._state - self._state = CacheState.UPDATING - self._rescan_changes = [] - - logger.info(f"Starting periodic rescan for connection {self.connection_id}") - - scan_start = time.monotonic() - scanned_count = 0 - error_count = 0 - - try: - pool = await get_connection_pool() - base_path = self._smb_base_path - - # Build a new set — don't modify the live one yet - new_directories: set[str] = set() - discovered: set[str] = set() - applied_changes = 0 - read_directories: set[str] = set() - - def apply_pending_changes() -> None: - """Called under the cache lock; delayed events make overlapping reads ambiguous.""" - nonlocal new_directories, applied_changes - changes = self._rescan_changes or [] - pending = changes[applied_changes:] - if any( - mutation.invalidates_read(directory) - for mutation in pending - if mutation.action != FileAction.FILE_ACTION_ADDED - for directory in read_directories - ): - raise RuntimeError("Directory namespace changed after an overlapping read started; keeping the existing index") - for mutation in pending: - new_directories = mutation.apply(new_directories) - applied_changes = len(changes) - - async with pool.get_connection( - host=self.host, - port=self.port, - username=self.username, - password=self.password, - share_name=self.share_name, - connection_cache=self._connection_cache, - ): - queue: list[str] = [""] - - while queue: - batch = queue[:SCAN_BATCH_SIZE] - queue = queue[SCAN_BATCH_SIZE:] - - for rel_path in batch: - if self._stop_event.is_set(): - logger.info(f"Rescan cancelled for connection {self.connection_id}") - self._state = previous_state - return - - if rel_path: - smb_path = f"{base_path}\\{rel_path.replace('/', '\\')}" - else: - smb_path = base_path - - try: - with self._lock: - apply_pending_changes() - read_directories.add(rel_path) - - subdirs = await self._scan_directory_with_pool_lease(pool, smb_path, rel_path) - - with self._lock: - apply_pending_changes() - - for subdir in subdirs: - if subdir.path not in discovered: - discovered.add(subdir.path) - new_directories.add(subdir.path) - if subdir.can_descend: - queue.append(subdir.path) - - scanned_count += 1 - except asyncio.TimeoutError: - logger.warning(f"Timeout during rescan of '{rel_path}' for connection {self.connection_id}") - error_count += 1 - except Exception as e: - logger.warning( - f"Error during rescan of '{rel_path}' for connection {self.connection_id}: {type(e).__name__}: {e}" - ) - error_count += 1 - - await asyncio.sleep(0) - - if error_count: - self._state = previous_state - self._scan_error = ( - f"Directory rescan incomplete: {error_count} directories could not be scanned; keeping the existing index." - ) - logger.warning("%s Connection: %s", self._scan_error, self.connection_id) - return - - # Atomically replace the cache - with self._lock: - apply_pending_changes() - self._directories = new_directories - self._directory_count = len(new_directories) - self._rescan_changes = None - - self._scan_error = None - self._last_scan_time = time.time() - self._state = CacheState.READY - elapsed = time.monotonic() - scan_start - logger.info( - f"Periodic rescan complete for connection {self.connection_id}: " - f"{self._directory_count} directories, " - f"{scanned_count} dirs scanned, {error_count} errors, " - f"{elapsed:.1f}s elapsed" - ) - - # Persist updated snapshot to disk - self.save_to_disk() - - except asyncio.CancelledError: - self._state = previous_state - raise - except Exception as e: - # Keep the old cache on failure - self._state = previous_state - self._scan_error = f"{type(e).__name__}: {e}" - elapsed = time.monotonic() - scan_start - logger.error( - f"Periodic rescan failed for connection {self.connection_id} after {elapsed:.1f}s: {type(e).__name__}: {e}", - exc_info=True, - ) - finally: - with self._lock: - self._rescan_changes = None - # ======================================================================== # CHANGE_NOTIFY watcher # ======================================================================== @@ -1033,10 +620,10 @@ def _start_watcher(self) -> None: self._watcher_thread = threading.Thread( target=self._watcher_loop, daemon=True, - name=f"dir-cache-watcher-{self.connection_id[:8]}", + name="directory-cache-watcher", ) self._watcher_thread.start() - logger.info(f"Started CHANGE_NOTIFY watcher for connection {self.connection_id}") + logger.info("Directory watcher started.", extra={"event": "directory_watcher_started"}) # # _watcher_loop @@ -1052,40 +639,56 @@ def _watcher_loop(self) -> None: retry_count = 0 while not self._stop_event.is_set(): + self._watcher_received_events = False try: self._connect_watcher() - retry_count = 0 # Reset on successful connect - self._run_watcher_inner() - except Exception as e: + except Exception as error: if self._stop_event.is_set(): break - retry_count += 1 - if retry_count > MAX_WATCHER_RETRIES: - logger.error( - f"CHANGE_NOTIFY watcher for connection {self.connection_id} exceeded max retries ({MAX_WATCHER_RETRIES}), stopping" + if retry_count == 0 or self._watcher_ready.is_set(): + self._observation_lost() + retry_count = 1 if self._watcher_received_events else retry_count + 1 + self._watcher_ready.clear() + if retry_count == MAX_WATCHER_RETRIES + 1: + self._logger.error( + "Directory watcher exhausted %s fast retries. Capped background retries remain active.", + MAX_WATCHER_RETRIES, + extra={"event": "directory_watcher_retry_exhausted"}, ) - break delay = min( - WATCHER_RETRY_BASE_DELAY * (2 ** (retry_count - 1)), + WATCHER_RETRY_BASE_DELAY * (2 ** min(retry_count - 1, MAX_WATCHER_RETRIES)), WATCHER_RETRY_MAX_DELAY, ) - logger.warning( - f"CHANGE_NOTIFY watcher error for connection {self.connection_id}, " - f"retrying in {delay:.0f}s (attempt {retry_count}/{MAX_WATCHER_RETRIES}): " - f"{type(e).__name__}: {e}" - ) + jitter = hashlib.sha256(self.connection_id.encode("utf-8")).digest()[0] + delay = min(WATCHER_RETRY_MAX_DELAY, delay * (90 + jitter % 21) / 100) + self._watcher_retry_at = time.monotonic() + delay + if retry_count <= MAX_WATCHER_RETRIES: + self._logger.warning( + "Directory watcher failed (%s); retrying in %.1f seconds.", + type(error).__name__, + delay, + extra={ + "event": "directory_watcher_retry", + "stage": "watcher", + "error_type": type(error).__name__, + "retry_seconds": delay, + }, + exc_info=retry_count == 1, + ) self._cleanup_watcher() if self._stop_event.wait(delay): break # Stop event was set during wait + self._watcher_ready.clear() + self._watcher_retry_at = 0.0 self._cleanup_watcher() - logger.info(f"CHANGE_NOTIFY watcher stopped for connection {self.connection_id}") + logger.info("Directory watcher stopped.", extra={"event": "directory_watcher_stopped"}) # # _connect_watcher @@ -1108,7 +711,7 @@ def _connect_watcher(self) -> None: username=self.username, password=self.password, require_encryption=bool(policy["encrypt"]), - auth_protocol=str(policy["auth_protocol"]), + auth_protocol=policy["auth_protocol"], ) self._watcher_session.connect() @@ -1130,7 +733,7 @@ def _connect_watcher(self) -> None: create_options=CreateOptions.FILE_DIRECTORY_FILE, ) - logger.debug(f"CHANGE_NOTIFY watcher connected for connection {self.connection_id} ({self.host}/{self.share_name})") + logger.debug("Directory watcher connected.", extra={"event": "directory_watcher_connected"}) # # _run_watcher_inner @@ -1154,66 +757,34 @@ def _run_watcher_inner(self) -> None: output_buffer_length=CHANGE_NOTIFY_BUFFER_SIZE, send=True, ) + if self._stop_event.is_set(): + break + if not self._watcher_ready.is_set(): + self._observation_lost() + self._watcher_ready.set() + self._watcher_retry_at = 0.0 try: result = watcher.wait() - except Exception as e: - error_msg = str(e).lower() - # STATUS_NOTIFY_ENUM_DIR (buffer overflow) — ignore per plan - if "0xc000010c" in error_msg or "notify_enum_dir" in error_msg: - logger.info(f"CHANGE_NOTIFY buffer overflow for connection {self.connection_id}, ignoring (next rescan will catch up)") - continue - raise + except NotifyEnumDir: + result = [] - if self._stop_event.is_set() or result is None: + if self._stop_event.is_set(): break + if result is None: + raise ConnectionError("Directory observation stopped unexpectedly. Reconnect and verify the source.") + if not result: + self._observation_lost() + logger.info("Directory observation details were lost; source verification is required.") + continue # Debounce: collect events for a short period self._process_change_events(result) + self._watcher_received_events = True # # _process_change_events # - def _process_change_events(self, events: list[dict]) -> None: # type: ignore[type-arg] - """Process CHANGE_NOTIFY events and update the cache. - - Args: - events: List of FILE_NOTIFY_INFORMATION dictionaries from the watcher - """ - - for event in events: - try: - action = event["action"].get_value() - # Filename is relative to the watched directory (share root) - # and uses backslash separators - raw_path = event["file_name"].get_value() - rel_path = raw_path.replace("\\", "/") - - if action == FileAction.FILE_ACTION_ADDED: - logger.debug(f"Directory added: '{rel_path}' (connection {self.connection_id})") - self.add_directory(rel_path) - - elif action == FileAction.FILE_ACTION_REMOVED: - logger.debug(f"Directory removed: '{rel_path}' (connection {self.connection_id})") - self.remove_directory(rel_path) - - elif action == FileAction.FILE_ACTION_RENAMED_OLD_NAME: - # Store the old name; the next event should be RENAMED_NEW_NAME - self._pending_rename_old = rel_path - - elif action == FileAction.FILE_ACTION_RENAMED_NEW_NAME: - old_path = getattr(self, "_pending_rename_old", None) - if old_path: - logger.debug(f"Directory renamed: '{old_path}' -> '{rel_path}' (connection {self.connection_id})") - self.rename_directory(old_path, rel_path) - self._pending_rename_old = None - else: - # Got rename-new without rename-old; treat as add - logger.debug(f"Directory rename-new without old: '{rel_path}' (connection {self.connection_id})") - self.add_directory(rel_path) - - except Exception as e: - logger.warning(f"Error processing CHANGE_NOTIFY event for connection {self.connection_id}: {type(e).__name__}: {e}") # # _cleanup_watcher @@ -1232,36 +803,36 @@ def _cleanup_watcher(self) -> None: if self._watcher_open: try: self._watcher_open.close() - except Exception as e: - logger.debug(f"Error closing watcher handle: {e}") + except Exception as error: + logger.debug("Directory watcher handle cleanup failed (%s).", type(error).__name__) finally: self._watcher_open = None if self._watcher_tree: try: self._watcher_tree.disconnect() - except Exception as e: - logger.debug(f"Error disconnecting watcher tree: {e}") + except Exception as error: + logger.debug("Directory watcher tree cleanup failed (%s).", type(error).__name__) finally: self._watcher_tree = None if self._watcher_session: try: self._watcher_session.disconnect() - except Exception as e: - logger.debug(f"Error disconnecting watcher session: {e}") + except Exception as error: + logger.debug("Directory watcher session cleanup failed (%s).", type(error).__name__) finally: self._watcher_session = None if self._watcher_connection: try: self._watcher_connection.disconnect() - except RuntimeError as e: + except RuntimeError as error: # smbprotocol bug: disconnect() tries to join the current # thread when called from the message-worker after a timeout. - logger.debug(f"Ignored RuntimeError during watcher connection disconnect: {e}") - except Exception as e: - logger.debug(f"Error disconnecting watcher connection: {e}") + logger.debug("Directory watcher connection cleanup failed (%s).", type(error).__name__) + except Exception as error: + logger.debug("Directory watcher connection cleanup failed (%s).", type(error).__name__) finally: self._watcher_connection = None @@ -1283,22 +854,10 @@ def _start_periodic_rescan(self) -> None: # # _periodic_rescan_loop # + @abstractmethod async def _periodic_rescan_loop(self) -> None: - """Periodically trigger a full rescan.""" - - while not self._stop_event.is_set(): - try: - await asyncio.sleep(RESCAN_INTERVAL_SECONDS) - if self._stop_event.is_set(): - break - await self._run_rescan() - except asyncio.CancelledError: - break - except Exception as e: - logger.error( - f"Error in periodic rescan for connection {self.connection_id}: {type(e).__name__}: {e}", - exc_info=True, - ) + """Run observation, verification, retry, and maintenance scheduling.""" + raise NotImplementedError # ======================================================================== # Shutdown @@ -1310,7 +869,7 @@ async def _periodic_rescan_loop(self) -> None: def stop(self) -> None: """Stop all background tasks and clean up resources.""" - logger.info(f"Stopping directory cache for connection {self.connection_id}") + logger.info("Stopping directory cache.", extra={"event": "directory_source_stopping"}) self._request_stop() self._finish_stop() @@ -1325,33 +884,903 @@ def _request_stop(self) -> None: if self._rescan_task and not self._rescan_task.done(): self._rescan_task.cancel() - if self._verification_task and not self._verification_task.done(): - self._verification_task.cancel() - if self._coalesce_task and not self._coalesce_task.done(): - self._coalesce_task.cancel() +class DirectorySourceUnavailableError(InterruptedError): + """A failed root read prevents retained results from leaving the binding.""" - def _finish_stop(self) -> None: - """Wait for blocking watcher cleanup after asynchronous tasks stop.""" - # Wait for watcher thread - if self._watcher_thread and self._watcher_thread.is_alive(): - self._watcher_thread.join(timeout=5.0) - if self._watcher_thread.is_alive(): - logger.warning(f"Watcher thread did not stop cleanly for connection {self.connection_id}") +class DirectoryScanCapacityError(TimeoutError): + """A source must retry without starting another blocking root probe.""" - self._cleanup_watcher() - # Flush dirty cache to disk before clearing - if self._dirty and self._directory_count > 0: - self.save_to_disk() +class SQLiteConnectionDirectoryCache(ConnectionDirectoryCache): + """Disk-backed SMB source with shared scan work and bounded observations.""" - with self._lock: - self._directories.clear() - self._directory_count = 0 + @property + def monitoring_state(self) -> str: + if not self._watcher_ready.is_set(): + return "unavailable" if self._watcher_retry_at else "starting" + return "recovering" if self.coordinator.verification_required else "healthy" - self._state = CacheState.EMPTY - logger.info(f"Directory cache stopped for connection {self.connection_id}") + @property + def retry_after_ms(self) -> int | None: + now = time.monotonic() + deadlines = (self._resource_retry_at, self._watcher_retry_at, self._verification_retry_at) + pending = [deadline - now for deadline in deadlines if deadline is not None and deadline > now] + return max(1, int(min(pending) * 1000)) if pending else None + + def __init__( + self, + connection_id: str, + host: str, + share_name: str, + username: str, + password: str, + port: int = 445, + path_prefix: str = "/", + source_revision: str = "", + *, + query_slots: threading.BoundedSemaphore | None = None, + scan_slots: threading.BoundedSemaphore | None = None, + ) -> None: + super().__init__(connection_id, host, share_name, username, password, port, path_prefix, source_revision) + self._source_identity["connection_id"] = connection_id + identity = json.dumps(self._source_identity, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + filename = hashlib.sha256(identity.encode("utf-8")).hexdigest() + SQLITE_CACHE_EXTENSION + self._persist_path = self._cache_root / SQLITE_CACHE_SUBDIR / filename + self.index = DirectoryIndex(self._persist_path, source_kind="smb", source_identity=self._source_identity) + self.policy = DirectoryIndexPolicy() + self.resources = DirectoryIndexResources(self._persist_path.parent, self.policy) + self.resources.register_source(self._persist_path) + self.index.growth_guard = self.resources.reserve_growth + self.coordinator = DirectoryScanCoordinator(self.index) + self._manager_query_slots = query_slots if query_slots is not None else threading.BoundedSemaphore(QUERY_WORKERS) + self._manager_scan_slots = scan_slots if scan_slots is not None else threading.BoundedSemaphore(1) + self._workers = threading.Condition() + self._finish_lock = threading.Lock() + self._storage_released = False + self._active_workers = 0 + self._opened = threading.Event() + self._generation_lock = threading.Lock() + self._scan_progress = time.monotonic() + self._scan_running = threading.Event() + self._resource_retry_at = 0.0 + self._verification_retry_at: float | None = None + self._next_full_pass_at = 0.0 + self._scan_epoch: tuple[int, int] | None = None + self._failure_retry_delay = 0.0 + self._retry_jitter = hashlib.sha256(identity.encode("utf-8")).digest()[0] + self._owning_loop: asyncio.AbstractEventLoop | None = None + self._last_access = time.monotonic() + self._last_access_at_ms = int(time.time() * 1000) + self._cleanup_pending = True + self._reclamation_pending = False + self._source_available = threading.Event() + self._source_probe_completed = threading.Event() + self._root_reading = threading.Event() + self._source_root = SmbDirectoryRoot(self._smb_base_path) + + def touch(self) -> None: + now = time.monotonic() + with self.coordinator.lock: + if now - self._last_access >= DIRECTORY_INDEX_CONTRACT.limits["idle_release_seconds"] and self._source_available.is_set(): + self.coordinator.invalidate() + self._source_available.clear() + self._source_probe_completed.set() + self._last_access = now + self._last_access_at_ms = int(time.time() * 1000) + + def can_release_idle(self) -> bool: + with self._workers: + return ( + time.monotonic() - self._last_access >= DIRECTORY_INDEX_CONTRACT.limits["idle_release_seconds"] + and not self._active_workers + and not self._scan_running.is_set() + and not self.index.has_active_queries + ) + + def _maintenance_blocked(self) -> bool: + return ( + self._stop_event.is_set() + or bool(self.coordinator.pending) + or self.index.has_active_queries + or self._scan_running.is_set() + or self.index.reconciliation_generation() is not None + ) + + def _cleanup_retired_work(self) -> None: + deadline = time.monotonic() + WORKER_POLL_SECONDS + while self._cleanup_pending and not self._stop_event.is_set() and time.monotonic() < deadline: + with self.coordinator.lock: + if self._maintenance_blocked(): + return + self._cleanup_pending = self.index.cleanup_retired_batch() + + def _reclaim_disk_work(self) -> None: + with self.coordinator.lock: + if not self._reclamation_pending or self._maintenance_blocked(): + return + self._reclamation_pending = not self.index.reclaim_pages() + + def _checkpoint_work(self) -> None: + with self.coordinator.lock: + if self._maintenance_blocked(): + return + self.index.checkpoint_if_due() + + def _repair_fts_work(self) -> None: + while not self._manager_scan_slots.acquire(timeout=WORKER_POLL_SECONDS): + if self._stop_event.is_set(): + raise InterruptedError("Directory source stopped. Reopen the connection to initialize it.") + try: + self._scan_running.set() + while self.index.needs_fts_repair: + if self._stop_event.is_set(): + raise InterruptedError("Directory source stopped. Reopen the connection to initialize it.") + with self.coordinator.lock: + self.index.repair_fts_batch() + finally: + self._scan_running.clear() + self._manager_scan_slots.release() + + async def _blocking(self, operation: Callable[[], WorkerResult], *, pool: SMBConnectionPool | None = None) -> WorkerResult: + def run() -> WorkerResult: + try: + if self._stop_event.is_set(): + raise InterruptedError("Directory source stopped. Reopen the connection to initialize it.") + return operation() + finally: + with self._workers: + self._active_workers -= 1 + self._workers.notify_all() + + with self._workers: + if self._stop_event.is_set(): + raise InterruptedError("Directory source stopped. Reopen the connection to initialize it.") + self._active_workers += 1 + try: + future = asyncio.get_running_loop().run_in_executor(None, run) + except BaseException: + with self._workers: + self._active_workers -= 1 + self._workers.notify_all() + raise + + def report_abandoned(completed: asyncio.Future[WorkerResult]) -> None: + if not completed.cancelled(): + error = completed.exception() + if error is not None: + logger.warning("Abandoned directory worker failed (%s).", type(error).__name__) + + try: + if pool is None: + return await wait_for_worker(future) + self._scan_progress = time.monotonic() + while True: + try: + return await wait_for_worker(future, WORKER_POLL_SECONDS) + except TimeoutError: + if future.done(): + return future.result() + if time.monotonic() - self._scan_progress >= SCAN_TIMEOUT_SECONDS: + self.coordinator.invalidate() + future.add_done_callback(report_abandoned) + await pool.retain_connection_until_future_complete(self._connection_cache, future) + raise TimeoutError("Directory read has stalled. Scan capacity stays reserved until the worker exits.") from None + except asyncio.CancelledError: + self.coordinator.invalidate() + future.add_done_callback(report_abandoned) + if pool is not None: + await pool.retain_connection_until_future_complete(self._connection_cache, future) + raise + + async def start_scan(self) -> None: + if self._stop_event.is_set() or time.monotonic() < self._resource_retry_at: + return + if self._scan_task is not None and not self._scan_task.done(): + return + self._owning_loop = asyncio.get_running_loop() + self._state = CacheState.BUILDING + self._scan_task = asyncio.create_task(self._initialize()) + + async def _initialize(self) -> None: + try: + await self._blocking(self._initialize_storage) + self._opened.set() + self._directory_count = (await self._blocking(self.index.status)).directory_count + self._start_watcher() + await self._run_rescan() + self._start_periodic_rescan() + except asyncio.CancelledError: + return + except Exception as error: + self._scan_error = ( + str(error) + if isinstance(error, DirectoryDiskBudgetError) + else "Directory index storage is in use. Wait for active workers to finish, then retry." + if isinstance(error, BlockingIOError) + else "Directory index initialization failed. Check private local storage and reopen the connection." + ) + self._state = CacheState.READY if self._opened.is_set() else CacheState.EMPTY + self._schedule_retry() + self._start_periodic_rescan() + logger.warning("Directory index initialization failed (%s).", type(error).__name__) + + def _initialize_storage(self) -> None: + self.apply_policy(get_directory_index_policy()) + self.index.open(rebuild_invalid=True) + self.coordinator.invalidate() + self.coordinator.apply_pending() + + def apply_policy(self, policy: DirectoryIndexPolicy) -> None: + changed = policy != self.policy + self.resources.apply_policy(policy) + self.index.configure_page_cache(policy.page_cache_kib) + self.policy = DirectoryIndexPolicy.model_validate(policy.model_dump()) + if changed: + self._reset_retry() + if not self._opened.is_set() and self._owning_loop is not None and not self._stop_event.is_set(): + if self._scan_task is None or self._scan_task.done(): + self._owning_loop.call_soon_threadsafe(lambda: asyncio.create_task(self.start_scan())) + + def search(self, query: str, max_results: int = MAX_SEARCH_RESULTS, *, include_dot_directories: bool = False) -> tuple[list[str], int]: + response = self.search_index(query, max_results, include_dot_directories=include_dot_directories) + return [row.path for row in response.results], response.total_matches + + def search_index( + self, + query: str, + max_results: int = MAX_SEARCH_RESULTS, + *, + include_dot_directories: bool = False, + cancelled: threading.Event | None = None, + ) -> DirectoryIndexSearch: + self.touch() + if self._stop_event.is_set(): + raise InterruptedError("Directory source was replaced or stopped. Retry the search on the current connection.") + if not self._opened.is_set(): + return DirectoryIndexSearch((), 0, 0, 0, "partial", None, "empty") + if not self._source_available.is_set(): + if not self._source_probe_completed.is_set(): + return DirectoryIndexSearch((), 0, 0, 0, "partial", None, "empty") + raise DirectorySourceUnavailableError( + "Directory source root is unavailable. Restore source access and retry the search after verification." + ) + if not self._manager_query_slots.acquire(timeout=WORKER_POLL_SECONDS): + raise TimeoutError("Directory query workers are busy. Wait briefly and retry the search.") + try: + response = self.index.search( + query.replace("\\", "/"), limit=max_results, include_dot_directories=include_dot_directories, cancelled=cancelled + ) + if self._stop_event.is_set(): + raise InterruptedError("Directory source was replaced or stopped. Retry the search on the current connection.") + if not self._source_available.is_set(): + raise DirectorySourceUnavailableError( + "Directory source root is unavailable. Restore source access and retry the search after verification." + ) + return response + finally: + self._manager_query_slots.release() + + def _apply_mutations(self, mutations: list[_DirectoryMutation]) -> None: + for mutation in mutations: + if not self.coordinator.enqueue(mutation): + self._scan_error = "Directory observations were lost. Background verification is required." + + def observe_parent(self, parent: str) -> None: + """Queue source validation without assuming that a destination is a directory.""" + self._apply_mutations([_DirectoryMutation(READ_PARENT_ACTION, parent.strip("/"))]) + + def _observation_lost(self) -> None: + self.coordinator.invalidate() + self._scan_error = "Directory observations were lost. Background verification is required." + + def _process_change_events(self, events: list[dict[str, Any]]) -> None: + """Treat rename directions independently and discover destinations from parents.""" + for event in events: + try: + action = event["action"].get_value() + path = event["file_name"].get_value().replace("\\", "/") + validate_directory_path(path) + parent = path.rpartition(PATH_SEPARATOR)[0] + if action in (FileAction.FILE_ACTION_REMOVED, FileAction.FILE_ACTION_RENAMED_OLD_NAME): + self._apply_mutations([_DirectoryMutation(FileAction.FILE_ACTION_REMOVED, path)]) + elif action not in (FileAction.FILE_ACTION_ADDED, FileAction.FILE_ACTION_RENAMED_NEW_NAME): + self._observation_lost() + continue + self._apply_mutations([_DirectoryMutation(READ_PARENT_ACTION, parent)]) + except Exception as error: + self._observation_lost() + logger.warning("Directory observation could not be queued (%s).", type(error).__name__) + + class _DescendantAccessDenied(PermissionError): + """A source operation failed, not a local index write.""" + + class _DescendantMissing(FileNotFoundError): + """A source directory disappeared, not a local index file.""" + + def _source_call(self, parent: str, operation: Callable[[], WorkerResult]) -> WorkerResult: + if not parent: + self._root_reading.set() + try: + return operation() + except ScanObservationInvalidated: + raise + except DirectoryBindingChangedError as error: + self._source_available.clear() + self._source_probe_completed.set() + self.coordinator.invalidate() + self._source_root = self._source_root.replacement() + raise DirectorySourceUnavailableError( + "Directory source is temporarily unavailable. Background verification will bind the root again." + ) from error + except Exception as error: + if parent: + if ( + isinstance(error, (FileNotFoundError, NotADirectoryError)) + or isinstance(error, SMBOSError) + and error.ntstatus + in (NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, NtStatus.STATUS_OBJECT_PATH_NOT_FOUND, NtStatus.STATUS_NOT_A_DIRECTORY) + or isinstance(error, SMBResponseException) + and error.status + in (NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, NtStatus.STATUS_OBJECT_PATH_NOT_FOUND, NtStatus.STATUS_NOT_A_DIRECTORY) + ): + raise self._DescendantMissing( + "Directory descendant disappeared. Verify its parent before retrying directory indexing." + ) from error + if ( + isinstance(error, PermissionError) + or (isinstance(error, SMBOSError) and error.ntstatus == NtStatus.STATUS_ACCESS_DENIED) + or (isinstance(error, SMBResponseException) and error.status == NtStatus.STATUS_ACCESS_DENIED) + ): + raise self._DescendantAccessDenied( + "Directory descendant access was denied. Restore source permissions and retry verification." + ) from error + raise + self._source_available.clear() + self._source_probe_completed.set() + raise DirectorySourceUnavailableError( + "Directory source root is unavailable. Restore source access and retry verification." + ) from error + finally: + if not parent: + self._root_reading.clear() + + @contextmanager + def _source_read(self, parent: str, close: Callable[[], None]) -> Iterator[None]: + """Keep the primary read failure when cleanup also fails.""" + try: + yield + except BaseException: + try: + self._source_call(parent, close) + except Exception as error: + logger.warning("Directory source read cleanup failed (%s).", type(error).__name__) + raise + else: + self._source_call(parent, close) + + def _probe_source_root(self) -> None: + if not self._manager_scan_slots.acquire(blocking=False): + raise DirectoryScanCapacityError("Directory scan capacity is in use. Wait for active work to finish, then retry.") + try: + self._scan_running.set() + with self.coordinator.read("") as token: + self._probe_source_root_read(token) + finally: + self._scan_running.clear() + self._manager_scan_slots.release() + + def _probe_source_root_read(self, token: ScanReadToken) -> None: + entries = self._source_call("", lambda: self._open_source_read("", token)) + with self._source_read("", entries.close): + self._source_call("", lambda: next(entries, None)) + self._source_call("", entries.verify) + if self._stop_event.is_set() or not self.coordinator.is_current(token): + raise ScanObservationInvalidated("Directory root observation became stale. Retry source verification.") + with self.coordinator.lock: + if self._stop_event.is_set() or not self.coordinator.is_current(token): + raise ScanObservationInvalidated("Directory root observation became stale. Retry source verification.") + self._source_available.set() + self._source_probe_completed.set() + + def _open_source_read(self, parent: str, token: ScanReadToken) -> SmbDirectoryRead: + def check() -> None: + if self._stop_event.is_set(): + raise InterruptedError("Directory source stopped. Reopen the connection to initialize it.") + if not self.coordinator.is_current(token): + raise ScanObservationInvalidated("Directory scan observation became stale. Restart source verification.") + self._scan_progress = time.monotonic() + + return self._source_root.read( + parent, + check, + username=self.username, + password=self.password, + port=self.port, + connection_cache=self._connection_cache, + **get_smbclient_policy_kwargs(), + ) + + def _scan_parent(self, generation: int, parent: str, *, reconcile: bool = False) -> None: + with self.coordinator.read(parent) as token: + try: + self._scan_parent_read(generation, parent, token, reconcile=reconcile) + except self._DescendantMissing as error: + self._recover_missing_parent(generation, parent, token, error, reconcile=reconcile) + except self._DescendantAccessDenied: + if self._stop_event.is_set(): + raise InterruptedError("Directory source stopped. Reopen the connection to initialize it.") from None + if reconcile: + raise + self.coordinator.record_access_denied(token, generation) + self.coordinator.finish_parent(token, generation) + except DirectoryTraversalError: + if self._stop_event.is_set(): + raise InterruptedError("Directory source stopped. Reopen the connection to initialize it.") from None + if reconcile: + raise + self.coordinator.record_unsafe_traversal(token, generation) + self.coordinator.finish_parent(token, generation) + + def _recover_missing_parent(self, generation: int, parent: str, token: ScanReadToken, error: Exception, *, reconcile: bool) -> None: + """Verify absence through a held ancestor before deferring the missing job.""" + ancestor = parent.rpartition(PATH_SEPARATOR)[0] + while True: + with self.coordinator.read(ancestor) as ancestor_token: + try: + entries = self._source_call(ancestor, lambda: self._open_source_read(ancestor, ancestor_token)) + except self._DescendantMissing: + ancestor = ancestor.rpartition(PATH_SEPARATOR)[0] + continue + child = parent[len(ancestor) + 1 :].split(PATH_SEPARATOR)[0] if ancestor else parent.split(PATH_SEPARATOR)[0] + with self._source_read(ancestor, entries.close): + if self._source_call(ancestor, lambda: entries.has_directory_child(child)): + raise error + self._source_call(ancestor, entries.verify) + with self.coordinator.lock: + if ( + self._stop_event.is_set() + or not self.coordinator.is_current(token) + or not self.coordinator.is_current(ancestor_token) + ): + raise ScanObservationInvalidated("Directory ancestor observation became stale. Retry source verification.") + self.index.queue_observed_parent(ancestor) + self.coordinator.finish_parent(token, generation, reconcile=reconcile) + return + + def _scan_parent_read(self, generation: int, parent: str, token: ScanReadToken, *, reconcile: bool = False) -> None: + entries = self._source_call(parent, lambda: self._open_source_read(parent, token)) + batch: list[tuple[str, bool]] = [] + batch_bytes = 0 + with self._source_read(parent, entries.close): + while True: + if self._stop_event.is_set(): + raise InterruptedError("Directory source stopped. Reopen the connection to initialize it.") + if not self.coordinator.is_current(token): + raise ScanObservationInvalidated("Directory scan observation became stale. Restart source verification.") + self._scan_progress = time.monotonic() + entry = self._source_call(parent, lambda: next(entries, None)) + if self._stop_event.is_set(): + raise InterruptedError("Directory source stopped. Reopen the connection to initialize it.") + if not self.coordinator.is_current(token): + raise ScanObservationInvalidated("Directory scan observation became stale. Restart source verification.") + if entry is None: + break + self._scan_progress = time.monotonic() + if entry.name is None: + if reconcile: + raise DirectoryTraversalError( + "Directory reconciliation found an unsupported name. Complete source verification is required." + ) + self.coordinator.record_unsafe_traversal(token, generation) + continue + if entry.name in (".", ".."): + continue + if PATH_SEPARATOR in entry.name or "\\" in entry.name: + raise ValueError("Directory entry has an unsupported separator. Source coverage is incomplete.") + attributes = entry.smb_info.file_attributes + if not attributes & FileAttributes.FILE_ATTRIBUTE_DIRECTORY: + continue + if attributes & FileAttributes.FILE_ATTRIBUTE_REPARSE_POINT: + if reconcile: + raise OSError("Directory reconciliation found a redirected directory. Complete source verification is required.") + self.coordinator.record_unsafe_traversal(token, generation) + continue + path = parent + PATH_SEPARATOR + entry.name if parent else entry.name + validate_directory_path(path) + path_bytes = len(path.encode("utf-8")) + if path_bytes > MAX_OBSERVATION_BYTES: + raise ValueError("Directory path exceeds the scan-batch byte limit. Source coverage is incomplete.") + if batch and batch_bytes + path_bytes > MAX_OBSERVATION_BYTES: + self._source_call(parent, entries.verify) + self.coordinator.commit_children(token, generation, batch, reconcile=reconcile) + batch.clear() + batch_bytes = 0 + batch.append((path, True)) + batch_bytes += path_bytes + if len(batch) == ROW_BATCH_SIZE: + self._source_call(parent, entries.verify) + self.coordinator.commit_children(token, generation, batch, reconcile=reconcile) + batch.clear() + batch_bytes = 0 + if batch: + self._source_call(parent, entries.verify) + self.coordinator.commit_children(token, generation, batch, reconcile=reconcile) + self._source_call(parent, entries.verify) + self._prune_missing_children(generation, parent, token, entries, reconcile=reconcile) + with self.coordinator.lock: + if self._stop_event.is_set(): + raise InterruptedError("Directory source stopped. Reopen the connection to initialize it.") + self.coordinator.finish_parent(token, generation, reconcile=reconcile) + if not parent: + self._source_available.set() + self._source_probe_completed.set() + + def _prune_missing_children( + self, generation: int, parent: str, token: ScanReadToken, entries: SmbDirectoryRead, *, reconcile: bool + ) -> None: + cursor: str | None = None + while True: + page = self.index.direct_child_paths(generation, parent, after_path=cursor) + if not page: + return + for path in page: + if self._source_call(parent, lambda: entries.has_directory_child(path.rsplit(PATH_SEPARATOR, 1)[-1])): + continue + more = True + while more: + self._source_call(parent, entries.verify) + with self.coordinator.lock: + if self._stop_event.is_set(): + raise InterruptedError("Directory source stopped. Reopen the connection to initialize it.") + if not self.coordinator.is_current(token): + raise ScanObservationInvalidated("Directory scan observation became stale. Restart source verification.") + self.coordinator.apply_pending() + if reconcile: + self.index.write_reconciliation_children(generation, parent, ()) + more = self.index.prune_subtree_batch(generation, path) + cursor = page[-1] + + def _reconcile_disk_queue(self) -> None: + """Read bounded accepted-parent work without advancing full-pass clocks.""" + if self._stop_event.is_set() or time.monotonic() < self._resource_retry_at or not self._source_available.is_set(): + return + if not self._generation_lock.acquire(blocking=False): + return + try: + if not self._manager_scan_slots.acquire(blocking=False): + return + try: + self._scan_running.set() + deadline = time.monotonic() + WORKER_POLL_SECONDS + generation = self.index.reconciliation_generation() + while generation is not None and time.monotonic() < deadline: + if self._stop_event.is_set(): + raise InterruptedError("Directory source stopped. Reopen the connection to initialize it.") + if self.index.reconciliation_generation() != generation: + return + parents = self.index.pending_scan_paths(generation, limit=1) + if not parents: + return + self._scan_parent(generation, parents[0], reconcile=True) + finally: + self._scan_running.clear() + self._manager_scan_slots.release() + finally: + self._generation_lock.release() + + async def _run_reconciliation(self) -> None: + if ( + self._stop_event.is_set() + or self._scan_running.is_set() + or time.monotonic() < self._resource_retry_at + or not self._source_available.is_set() + or await self._blocking(self.index.reconciliation_generation) is None + ): + return + connected = False + try: + await self._refresh_connection_generation() + pool = await get_connection_pool() + async with pool.get_connection( + host=self.host, + port=self.port, + username=self.username, + password=self.password, + share_name=self.share_name, + connection_cache=self._connection_cache, + ): + connected = True + await self._blocking(self._reconcile_disk_queue, pool=pool) + except ScanObservationInvalidated: + raise + except Exception: + if not connected: + self._source_available.clear() + self._source_probe_completed.set() + raise + + def _scan_disk_queue(self) -> None: + while not self._manager_scan_slots.acquire(timeout=WORKER_POLL_SECONDS): + self._scan_progress = time.monotonic() + if self._stop_event.is_set(): + raise InterruptedError("Directory source stopped. Reopen the connection to initialize it.") + started = time.monotonic() + success = False + try: + self._scan_running.set() + self._logger.debug("Directory scan started.", extra={"event": "directory_scan_started", "stage": "scan"}) + self._scan_progress = time.monotonic() + with self._generation_lock, self.coordinator.read("") as baseline: + with self.coordinator.lock: + self.coordinator.apply_pending() + resuming = self.index.has_resumable_scan() + generation, _selected = self.index.prepare_scan() + if not resuming: + self._scan_epoch = (generation, baseline.epoch) + self._cleanup_pending = True + self.index.seed_scan(generation) + try: + while not self._stop_event.is_set(): + with self.coordinator.lock: + if baseline.epoch != self.coordinator.epoch: + raise ScanObservationInvalidated("Directory scan epoch changed. Retry source verification.") + self.coordinator.apply_pending() + if self.coordinator.pending: + continue + reconciliation = self.index.reconciliation_generation() + current = reconciliation if reconciliation is not None else generation + parents = self.index.pending_scan_paths(current, limit=1) + if not parents: + if self._scan_epoch != (generation, baseline.epoch): + while True: + with self.coordinator.lock: + if self._stop_event.is_set() or baseline.epoch != self.coordinator.epoch: + raise ScanObservationInvalidated("Directory scan epoch changed. Retry source verification.") + more = self.index.requeue_completed_scan_batch(generation) + if not more: + self._scan_epoch = (generation, baseline.epoch) + break + continue + with self.coordinator.read("") as root_token: + self._probe_source_root_read(root_token) + with self.coordinator.lock: + if self._stop_event.is_set(): + raise InterruptedError("Directory source stopped. Reopen the connection to initialize it.") + complete = self.coordinator.accept_generation(baseline, generation) + self._cleanup_pending = True + self._state = CacheState.READY + self._scan_error = ( + "Directory coverage is incomplete. Some paths cannot be indexed safely." + if not complete + else None + if self._watcher_ready.is_set() + else "Directory monitoring is unavailable. Background verification remains active." + ) + if complete: + self._last_scan_time = time.time() + success = complete + break + self._scan_parent(current, parents[0], reconcile=reconciliation is not None) + status = self.index.status() + self._directory_count = status.directory_count + else: + raise InterruptedError("Directory source stopped. Reopen the connection to initialize it.") + except BaseException: + self.coordinator.invalidate() + raise + finally: + self._scan_running.clear() + self._manager_scan_slots.release() + self._logger.debug( + "Directory scan finished (success=%s).", + success, + extra={"event": "directory_scan_finished", "stage": "scan", "duration_ms": (time.monotonic() - started) * 1000}, + ) + + def _schedule_observation_retry(self) -> None: + """Coalesce stale-read retries without classifying them as source failures.""" + now = time.monotonic() + deadline = now + OBSERVATION_RETRY_SECONDS + if self._verification_retry_at is None or self._verification_retry_at <= now: + self._verification_retry_at = deadline + else: + self._verification_retry_at = min(self._verification_retry_at, deadline) + self._state = CacheState.READY if self._directory_count else CacheState.EMPTY + self._scan_error = "Directory source changed during verification. Background verification will retry." + + def _schedule_retry(self, *, now: float | None = None) -> None: + """Spread bounded retries without changing the configured full-pass interval.""" + self._failure_retry_delay = ( + FAILURE_RETRY_MIN_SECONDS if not self._failure_retry_delay else min(self._failure_retry_delay * 2, FAILURE_RETRY_MAX_SECONDS) + ) + percent = 100 - FAILURE_RETRY_JITTER_PERCENT + self._retry_jitter % (FAILURE_RETRY_JITTER_PERCENT * 2 + 1) + delay = min(FAILURE_RETRY_MAX_SECONDS, max(FAILURE_RETRY_MIN_SECONDS, self._failure_retry_delay * percent / 100)) + self._resource_retry_at = (time.monotonic() if now is None else now) + delay + self._logger.debug( + "Directory retry scheduled.", extra={"event": "directory_retry_scheduled", "stage": "verification", "retry_seconds": delay} + ) + + def _reset_retry(self) -> None: + if self._failure_retry_delay: + self._logger.info( + "Directory verification recovered.", extra={"event": "directory_verification_recovered", "stage": "verification"} + ) + self._failure_retry_delay = 0.0 + self._resource_retry_at = 0.0 + self._verification_retry_at = None + + def _verification_due(self, verified_at: float, *, now: float | None = None) -> bool: + current = time.monotonic() if now is None else now + interval = ( + self.policy.degraded_verification_seconds + if self.coordinator.verification_required or not self._watcher_ready.is_set() + else self.policy.healthy_verification_seconds + ) + delay = interval * (100 - self._retry_jitter % (PERIODIC_VERIFICATION_JITTER_PERCENT + 1)) / 100 + return current >= self._resource_retry_at and ( + bool(self._failure_retry_delay) + or (self._verification_retry_at is not None and current >= self._verification_retry_at) + or current >= verified_at + delay + ) + + async def _refresh_connection_generation(self) -> None: + replacement = get_smb_connection_cache(self.connection_id) + if replacement is self._connection_cache: + return + with self._workers: + if self._active_workers: + raise DirectoryScanCapacityError( + "Previous directory work is still running. Wait for its cleanup before retrying verification." + ) + self.coordinator.invalidate() + self._source_available.clear() + self._source_probe_completed.set() + self._source_root = await self._blocking(self._source_root.replacement) + self._connection_cache = replacement + + async def _run_rescan(self) -> None: + now = time.monotonic() + if ( + not self._opened.is_set() + or self._scan_running.is_set() + or now < self._resource_retry_at + or (self._verification_retry_at is not None and now < self._verification_retry_at) + ): + return + resumable = await self._blocking(self.index.has_resumable_scan) + if now < self._next_full_pass_at and not resumable and self._source_available.is_set(): + return + self._state = CacheState.UPDATING if self._directory_count else CacheState.BUILDING + root_checked = False + try: + await self._refresh_connection_generation() + pool = await get_connection_pool() + async with pool.get_connection( + host=self.host, + port=self.port, + username=self.username, + password=self.password, + share_name=self.share_name, + connection_cache=self._connection_cache, + ): + await self._blocking(self._probe_source_root, pool=pool) + root_checked = True + if now < self._next_full_pass_at and not resumable: + self._state = CacheState.READY if self._directory_count else CacheState.EMPTY + return + if self.index.needs_fts_repair: + self._scan_error = "Directory search index is being rebuilt. Existing directory rows remain searchable." + await self._blocking(self._repair_fts_work) + if not resumable: + self._next_full_pass_at = time.monotonic() + DIRECTORY_INDEX_CONTRACT.limits["minimum_full_pass_spacing_seconds"] + await self._blocking(self._scan_disk_queue, pool=pool) + self._reset_retry() + except asyncio.CancelledError: + self.coordinator.invalidate() + raise + except ScanObservationInvalidated: + self._schedule_observation_retry() + except Exception as error: + capacity_busy = isinstance(error, DirectoryScanCapacityError) + if not capacity_busy and (not root_checked or self._root_reading.is_set()): + self._source_available.clear() + self._source_probe_completed.set() + if isinstance(error, DirectoryDiskBudgetError): + self._reclamation_pending = True + self._scan_error = ( + str(error) + if capacity_busy + else "Directory source root is unavailable. Restore source access and retry verification." + if not self._source_available.is_set() + else str(error) + if isinstance(error, DirectoryDiskBudgetError) + else "Directory read has stalled. Existing results remain available; scan capacity stays reserved until the worker exits." + if isinstance(error, TimeoutError) + else "Directory verification failed. Check source access; existing results remain available." + ) + self._state = CacheState.READY if self._directory_count else CacheState.EMPTY + if not self._failure_retry_delay: + self._logger.warning( + "Directory verification failed. Background retries remain active.", + extra={"event": "directory_verification_unavailable", "stage": "verification", "error_type": type(error).__name__}, + exc_info=True, + ) + self._schedule_retry() + + async def _periodic_rescan_loop(self) -> None: + verified_at = time.monotonic() + while not self._stop_event.is_set(): + try: + await asyncio.sleep(OBSERVATION_POLL_SECONDS) + if time.monotonic() < self._resource_retry_at: + continue + if not self._opened.is_set(): + await self.start_scan() + return + if self._opened.is_set(): + if self.index.needs_fts_repair or not self._source_available.is_set(): + await self._run_rescan() + verified_at = time.monotonic() + continue + await self._blocking(self.coordinator.apply_pending) + await self._run_reconciliation() + response = await self._blocking(self.index.status) + self._directory_count = response.directory_count + if self._cleanup_pending: + await self._blocking(self._cleanup_retired_work) + if self._reclamation_pending: + await self._blocking(self._reclaim_disk_work) + if not self._scan_running.is_set(): + await self._blocking(self._checkpoint_work) + if self._verification_due(verified_at): + await self._run_rescan() + verified_at = time.monotonic() + except asyncio.CancelledError: + return + except ScanObservationInvalidated: + self._schedule_observation_retry() + except Exception as error: + self.coordinator.invalidate() + if isinstance(error, DirectoryDiskBudgetError): + self._reclamation_pending = True + self._scan_error = ( + str(error) + if isinstance(error, DirectoryDiskBudgetError) + else "Directory updates failed. Check source access and private local storage, then retry." + ) + self._schedule_retry() + logger.warning("Directory updates paused (%s).", type(error).__name__) + + def _request_stop(self) -> None: + self.coordinator.invalidate() + super()._request_stop() + + def _finish_stop(self) -> None: + with self._finish_lock: + if self._storage_released: + return + with self._workers: + while self._active_workers: + self._workers.wait() + self._cleanup_watcher() + if self._watcher_thread is not None and self._watcher_thread.is_alive(): + self._watcher_thread.join() + try: + self._source_root.close() + except Exception as error: + logger.warning("Directory root handle cleanup failed (%s).", type(error).__name__) + try: + if self._opened.is_set(): + self.index.touch_access(self._last_access_at_ms) + except Exception: + logger.warning("Directory last-access time could not be saved. Closed cache data can be rebuilt after cleanup.") + self.index.close() + self.resources.release_source(self._persist_path) + self._storage_released = True + self._opened.clear() + self._state = CacheState.EMPTY # ============================================================================ @@ -1370,9 +1799,57 @@ class DirectoryCacheManager: # # __init__ # - def __init__(self) -> None: + def __init__(self, cache_factory: type[ConnectionDirectoryCache] = SQLiteConnectionDirectoryCache) -> None: self._caches: dict[str, ConnectionDirectoryCache] = {} self._lock = threading.Lock() + self._cache_factory = cache_factory + self._query_slots = threading.BoundedSemaphore(QUERY_WORKERS) + self._scan_slots = threading.BoundedSemaphore(1) + namespace = ConnectionDirectoryCache._resolve_cache_root() / SQLITE_CACHE_SUBDIR + self.resources = DirectoryIndexResources(namespace) + self._idle_task: asyncio.Task[None] | None = None + self._retiring_caches: set[SQLiteConnectionDirectoryCache] = set() + + async def release_idle_caches(self) -> None: + with self._lock: + expired = [ + cache for cache in self._caches.values() if isinstance(cache, SQLiteConnectionDirectoryCache) and cache.can_release_idle() + ] + for cache in expired: + self._caches.pop(cache.connection_id) + self._retiring_caches.add(cache) + cache._request_stop() + if expired: + closing = asyncio.gather(*(self._finish_idle_cache(cache) for cache in expired)) + closing.add_done_callback(lambda completed: completed.exception() if not completed.cancelled() else None) + await wait_for_worker(closing) + + async def _finish_idle_cache(self, cache: SQLiteConnectionDirectoryCache) -> None: + await asyncio.to_thread(cache._finish_stop) + with self._lock: + self._retiring_caches.discard(cache) + + async def _idle_release_loop(self) -> None: + while True: + try: + await asyncio.sleep(OBSERVATION_POLL_SECONDS) + await self.release_idle_caches() + except asyncio.CancelledError: + return + except Exception as error: + logger.error( + "Directory idle release failed (%s). Existing sources remain protected; retrying background cleanup.", + type(error).__name__, + extra={"event": "directory_idle_release_failed", "error_type": type(error).__name__}, + ) + + def apply_policy(self, policy: DirectoryIndexPolicy) -> None: + self.resources.apply_policy(policy) + with self._lock: + caches = list(self._caches.values()) + for cache in caches: + if isinstance(cache, SQLiteConnectionDirectoryCache): + cache.apply_policy(policy) # # get_cache @@ -1406,9 +1883,8 @@ async def get_or_create_cache( ) -> ConnectionDirectoryCache: """Get or create a directory cache for a connection. - If the cache doesn't exist, creates it. Tries to load a persisted - snapshot first for instant availability; always starts a background - scan (which doubles as a verification / refresh of the snapshot). + If the cache doesn't exist, create it and start shared background + initialization. Only validated SQLite catalogues can supply results. Args: connection_id: UUID string of the connection @@ -1425,10 +1901,21 @@ async def get_or_create_cache( """ with self._lock: - if connection_id in self._caches: - return self._caches[connection_id] + previous = self._caches.get(connection_id) + if previous is not None and ( + previous.host == host + and previous.share_name == share_name + and previous.username == username + and previous.password == password + and previous.port == port + and previous._path_prefix == (path_prefix or "/").replace("\\", "/").strip("/") + and previous._source_revision == source_revision + ): + if isinstance(previous, SQLiteConnectionDirectoryCache): + previous.touch() + return previous - cache = ConnectionDirectoryCache( + cache = self._cache_factory( connection_id=connection_id, host=host, share_name=share_name, @@ -1438,22 +1925,27 @@ async def get_or_create_cache( path_prefix=path_prefix, source_revision=source_revision, ) + if isinstance(cache, SQLiteConnectionDirectoryCache): + cache._manager_query_slots = self._query_slots + cache._manager_scan_slots = self._scan_slots + cache.resources = self.resources + self.resources.register_source(cache._persist_path) + cache.index.growth_guard = self.resources.reserve_growth + if self._idle_task is None or self._idle_task.done(): + self._idle_task = asyncio.create_task(self._idle_release_loop()) self._caches[connection_id] = cache - # Try to load cached snapshot from disk for instant availability - loaded = cache.load_from_disk() - - if loaded: - # Snapshot loaded — start a background rescan to verify freshness, - # plus the watcher and coalesce flush loop - cache._start_watcher() - cache._start_periodic_rescan() - cache._start_coalesce_flush() - # Also kick off a verification rescan immediately (non-blocking) - cache._verification_task = asyncio.create_task(cache._run_rescan()) - else: - # No snapshot — do a full scan from scratch - await cache.start_scan() + if previous is not None: + previous._request_stop() + if isinstance(previous, SQLiteConnectionDirectoryCache): + with self._lock: + self._retiring_caches.add(previous) + closing = asyncio.create_task(self._finish_idle_cache(previous)) + closing.add_done_callback(lambda completed: completed.exception() if not completed.cancelled() else None) + else: + await asyncio.to_thread(previous._finish_stop) + + await cache.start_scan() return cache @@ -1472,7 +1964,7 @@ def remove_cache(self, connection_id: str) -> None: if cache: cache.stop() - logger.info(f"Removed directory cache for connection {connection_id}") + logger.info("Directory cache removed.", extra={"event": "directory_source_removed"}) async def remove_cache_async(self, connection_id: str) -> None: """Remove one cache without blocking the application event loop.""" @@ -1486,10 +1978,14 @@ async def remove_cache_async(self, connection_id: str) -> None: cache._request_stop() try: await asyncio.to_thread(cache._finish_stop) - except Exception: - logger.exception("Error stopping cache for connection %s", connection_id) + except Exception as error: + logger.error( + "Directory cache shutdown failed (%s). Storage ownership remains held until workers exit.", + type(error).__name__, + extra={"event": "directory_source_shutdown_failed", "error_type": type(error).__name__}, + ) else: - logger.info(f"Removed directory cache for connection {connection_id}") + logger.info("Directory cache removed.", extra={"event": "directory_source_removed"}) # # stop_all @@ -1498,16 +1994,19 @@ def stop_all(self) -> None: """Stop all caches and clean up resources.""" with self._lock: - caches = list(self._caches.values()) + caches = list(self._caches.values()) + list(self._retiring_caches) self._caches.clear() + if self._idle_task is not None: + self._idle_task.cancel() for cache in caches: try: cache.stop() - except Exception as e: + except Exception as error: logger.error( - f"Error stopping cache for connection {cache.connection_id}: {e}", - exc_info=True, + "Directory cache shutdown failed (%s). Storage ownership remains held until workers exit.", + type(error).__name__, + extra={"event": "directory_source_shutdown_failed", "error_type": type(error).__name__}, ) logger.info("All directory caches stopped") @@ -1516,8 +2015,10 @@ async def stop_all_async(self) -> None: """Stop all caches without blocking the application event loop.""" with self._lock: - caches = list(self._caches.values()) + caches = list(self._caches.values()) + list(self._retiring_caches) self._caches.clear() + if self._idle_task is not None: + self._idle_task.cancel() for cache in caches: cache._request_stop() @@ -1529,9 +2030,13 @@ async def stop_all_async(self) -> None: for cache, result in zip(caches, results, strict=True): if isinstance(result, BaseException): logger.error( - f"Error stopping cache for connection {cache.connection_id}: {result}", - exc_info=result, + "Directory cache shutdown failed (%s). Storage ownership remains held until workers exit.", + type(result).__name__, + extra={"event": "directory_source_shutdown_failed", "error_type": type(result).__name__}, ) + elif isinstance(cache, SQLiteConnectionDirectoryCache): + with self._lock: + self._retiring_caches.discard(cache) logger.info("All directory caches stopped") diff --git a/backend/app/services/directory_index.py b/backend/app/services/directory_index.py new file mode 100644 index 000000000..937c9b645 --- /dev/null +++ b/backend/app/services/directory_index.py @@ -0,0 +1,1451 @@ +"""Disk-backed directory storage; callers own source authorization and worker admission.""" + +from __future__ import annotations + +import heapq +import json +import logging +import os +import sqlite3 +import stat +import sys +import threading +import time +import unicodedata +from bisect import insort +from contextlib import AbstractContextManager, closing, contextmanager, nullcontext +from dataclasses import dataclass +from itertools import islice +from pathlib import Path +from typing import Any, Callable, Iterable, Iterator, Literal + +from app.core.directory_index_contract import DIRECTORY_INDEX_CONTRACT, MAX_SQL_INTEGER + +APPLICATION_ID = 1396853073 +SCHEMA_VERSION = DIRECTORY_INDEX_CONTRACT.limits["schema_version"] +REQUIRED_DIRECTORY_TABLES = ("source_metadata", "generations", "directories", "scan_queue", "coverage_issues") +NORMALIZATION_VERSION = DIRECTORY_INDEX_CONTRACT.limits["normalization_version"] +TRAVERSAL_VERSION = DIRECTORY_INDEX_CONTRACT.limits["traversal_version"] +MAX_QUERY_BYTES = DIRECTORY_INDEX_CONTRACT.limits["max_query_bytes"] +MAX_QUERY_TERMS = DIRECTORY_INDEX_CONTRACT.limits["max_query_terms"] +MAX_RESULT_LIMIT = DIRECTORY_INDEX_CONTRACT.limits["max_result_limit"] +ROW_BATCH_SIZE = DIRECTORY_INDEX_CONTRACT.limits["row_batch_size"] +SCAN_BATCH_BYTES = DIRECTORY_INDEX_CONTRACT.limits["max_observation_bytes"] +MAINTENANCE_PAGE_BATCH = DIRECTORY_INDEX_CONTRACT.limits["maintenance_page_batch"] +CHECKPOINT_WAL_BYTES = DIRECTORY_INDEX_CONTRACT.limits["checkpoint_wal_bytes"] +NAMESPACE_LEASE_NAME = ".namespace.lease" +QUARANTINE_FILE_NAME = ".rejected-directory-index" +INCREMENTAL_VACUUM_MODE = 2 +PAGE_CACHE_KIB = DIRECTORY_INDEX_CONTRACT.policy["page_cache_kib"].default +SQL_BUSY_TIMEOUT_MS = DIRECTORY_INDEX_CONTRACT.limits["busy_timeout_ms"] +QUERY_WORKERS = DIRECTORY_INDEX_CONTRACT.limits["query_workers"] +SQL_PROGRESS_STEPS = DIRECTORY_INDEX_CONTRACT.limits["progress_steps"] +QUERY_TIMEOUT_SECONDS = DIRECTORY_INDEX_CONTRACT.limits["query_timeout_seconds"] +MAX_SIGNED_INTEGER = MAX_SQL_INTEGER +SCHEMA_PATH_CANDIDATES = tuple(Path(__file__).resolve().parents[level] / "shared" / "directory-index" / "schema.sql" for level in (3, 2)) +SCHEMA_PATH = next((path for path in SCHEMA_PATH_CANDIDATES if path.is_file()), SCHEMA_PATH_CANDIDATES[0]) +PRIVATE_DIRECTORY_MODE = 0o700 +LEASE_FILE_MODE = 0o600 +ROOT_USER_ID = 0 +STORAGE_FILE_SUFFIXES = ("", "-wal", "-shm", "-journal", ".lease") +MIN_SQLITE_VERSION_NUMBER = DIRECTORY_INDEX_CONTRACT.limits["minimum_sqlite_version_number"] +MIN_SQLITE_VERSION = (MIN_SQLITE_VERSION_NUMBER // 1_000_000, MIN_SQLITE_VERSION_NUMBER // 1_000 % 1_000, MIN_SQLITE_VERSION_NUMBER % 1_000) +SQLITE_PRIMARY_ERROR_MASK = 0xFF +SLOW_TRANSACTION_SECONDS = 1.0 +logger = logging.getLogger(__name__) + + +class DirectoryIndexRebuildRequired(ValueError): + """Validated application storage cannot publish its disposable directory data.""" + + +def validate_storage_ancestors(directory: Path) -> None: + """Reject storage ancestry controlled or replaceable by another account.""" + directory = directory.absolute() + for ancestor in (directory, *directory.parents): + try: + metadata = ancestor.lstat() + except FileNotFoundError: + continue + if stat.S_ISLNK(metadata.st_mode): + raise PermissionError("Directory-index storage must not use symbolic links. Select a private local directory.") + if not stat.S_ISDIR(metadata.st_mode): + raise PermissionError("Directory-index storage ancestors must be real directories. Select private local storage.") + if os.name == "posix": + if ancestor == directory and (metadata.st_uid != os.getuid() or stat.S_IMODE(metadata.st_mode) & 0o077): + raise PermissionError( + "Directory-index storage must belong to the service account with mode 0700. Correct its owner and permissions." + ) + if metadata.st_uid not in {os.getuid(), ROOT_USER_ID} or ( + metadata.st_mode & (stat.S_IWGRP | stat.S_IWOTH) and not metadata.st_mode & stat.S_ISVTX + ): + raise PermissionError( + "Directory-index storage has an unsafe ancestor. Use directories owned by the service account or root, " + "and remove group/public write access or use a trusted sticky parent." + ) + + +def connect_directory_database(path: Path, *, readonly: bool = False) -> sqlite3.Connection: + """Open existing private storage without creating a file or accepting a link.""" + path = path.absolute() + validate_storage_ancestors(path.parent) + DirectoryIndex._validate_storage_files(path) + before = path.lstat() + if not stat.S_ISREG(before.st_mode): + raise PermissionError("Directory-index database must be a regular file without symbolic links. Correct private cache storage.") + mode = "ro" if readonly else "rw" + connection = sqlite3.connect( + f"{path.as_uri()}?mode={mode}", + uri=True, + timeout=SQL_BUSY_TIMEOUT_MS / 1000, + isolation_level=None, + check_same_thread=False, + ) + try: + after = path.lstat() + if not stat.S_ISREG(after.st_mode) or (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino): + raise PermissionError("Directory-index database changed during open. Stop the source and correct private cache storage.") + validate_storage_ancestors(path.parent) + DirectoryIndex._validate_storage_files(path) + return connection + except BaseException: + connection.close() + raise + + +@contextmanager +def held_storage_directory(directory: Path) -> Iterator[int | None]: + """Keep POSIX namespace mutations bound to the directory that was validated.""" + validate_storage_ancestors(directory) + descriptor = os.open(directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) if os.name == "posix" else None + try: + validate_storage_binding(directory, descriptor) + yield descriptor + finally: + if descriptor is not None: + os.close(descriptor) + + +def validate_storage_binding(directory: Path, descriptor: int | None) -> None: + """Refuse a namespace replacement before a retained-directory mutation.""" + validate_storage_ancestors(directory) + if descriptor is not None: + held = os.fstat(descriptor) + current = directory.lstat() + if (held.st_dev, held.st_ino) != (current.st_dev, current.st_ino): + raise PermissionError("Directory storage changed during cleanup. Restore the private namespace and retry cleanup.") + + +@contextmanager +def transaction(connection: sqlite3.Connection) -> Iterator[sqlite3.Connection]: + """Include initial reads and all changes in one explicitly started transaction.""" + started = time.monotonic() + try: + with connection: + connection.execute("BEGIN") + yield connection + except sqlite3.DatabaseError as error: + logger.warning( + "Directory transaction failed. Restore storage or retry after contention.", + extra={ + "event": "directory_transaction_failed", + "sqlite_errorcode": getattr(error, "sqlite_errorcode", 0) & SQLITE_PRIMARY_ERROR_MASK, + }, + ) + raise + duration = time.monotonic() - started + if duration >= SLOW_TRANSACTION_SECONDS: + logger.warning( + "Directory transaction was slow.", + extra={"event": "directory_transaction_slow", "duration_ms": round(duration * 1000, 3)}, + ) + + +def validate_sqlite_runtime() -> None: + """Check the SQL features used by the index in the actual Python runtime.""" + if sqlite3.sqlite_version_info < MIN_SQLITE_VERSION or sqlite3.threadsafety != 3: + raise RuntimeError("Directory indexing requires SQLite 3.37 or later with serialized threading. Rebuild the Debian backend image.") + try: + with closing(sqlite3.connect(":memory:")) as connection: + connection.execute("CREATE TABLE runtime_rows(id INTEGER PRIMARY KEY) STRICT") + connection.execute("INSERT INTO runtime_rows DEFAULT VALUES RETURNING id").fetchall() + connection.execute("CREATE VIRTUAL TABLE runtime_fts USING fts5(path, tokenize='trigram case_sensitive 1', detail=full)") + connection.execute("INSERT INTO runtime_fts(path) VALUES ('runtime')") + if connection.execute("SELECT path FROM runtime_fts WHERE runtime_fts MATCH ?", ('"unt"',)).fetchone() != ("runtime",): + raise RuntimeError("Directory indexing requires working FTS5 trigram search. Rebuild the Debian backend image.") + except sqlite3.Error as exc: + raise RuntimeError( + "Directory indexing requires STRICT tables, RETURNING, and FTS5 trigram search. Rebuild the Debian backend image." + ) from exc + + +def normalize_search_text(value: str) -> str: + decomposed = unicodedata.normalize("NFKD", value) + return "".join(character for character in decomposed if not unicodedata.combining(character)).casefold() + + +def search_terms(query: str) -> tuple[str, ...]: + if len(query.encode("utf-8", errors="strict")) > MAX_QUERY_BYTES: + raise ValueError(f"Search text must use at most {MAX_QUERY_BYTES} UTF-8 bytes. Shorten the search text.") + terms = tuple(sorted({normalized for term in query.split() if (normalized := normalize_search_text(term))})) + if len(terms) > MAX_QUERY_TERMS: + raise ValueError(f"Search text must contain at most {MAX_QUERY_TERMS} distinct terms. Remove some terms.") + return terms + + +def required_row(cursor: sqlite3.Cursor) -> tuple[Any, ...]: + row = cursor.fetchone() + if not isinstance(row, tuple): + raise ValueError("Directory-index metadata is missing or invalid. Rebuild this source index.") + return row + + +def sql_text(*values: object) -> str: + if len(values) != 1 or not isinstance(values[0], str): + raise ValueError("Directory-index search key input is invalid. Rebuild this source index.") + return values[0] + + +def sql_normalize_key(*values: object) -> str: + return normalize_search_text(sql_text(*values)) + + +def sql_basename_key(*values: object) -> str: + return normalize_search_text(sql_text(*values).rsplit("/", 1)[-1]) + + +def sql_dot_path(*values: object) -> int: + return int(any(part.startswith(".") for part in sql_text(*values).split("/"))) + + +def single_term_rank(basename_key: str, term: str) -> int: + if basename_key == term: + return 0 + if basename_key.startswith(term): + return 1 + for offset, character in enumerate(basename_key): + if not character.isalnum() and basename_key[offset + 1 :].startswith(term): + return 2 + return 3 + + +def validate_directory_path(path: str) -> None: + path.encode("utf-8", errors="strict") + if "\x00" in path or any(component in {"", ".", ".."} for component in path.split("/")): + raise ValueError("Directory path must be relative and must not contain empty, dot, parent, or NUL components.") + + +def epoch_milliseconds() -> int: + value = time.time_ns() // 1_000_000 + if not 0 <= value <= MAX_SIGNED_INTEGER: + raise OverflowError("System time is outside the supported directory-index timestamp range. Correct the system clock.") + return value + + +@dataclass(frozen=True) +class DirectoryIndexMatch: + path: str + matched_term_count: int + match_rank: int + depth: int + path_key: str + + @property + def sort_key(self) -> tuple[int, int, int, str, str]: + return (-self.matched_term_count, self.match_rank, self.depth, self.path_key, self.path) + + +@dataclass(frozen=True) +class DirectoryIndexSearch: + results: tuple[DirectoryIndexMatch, ...] + total_matches: int + directory_count: int + accepted_revision: int + coverage: str + last_verified_at_ms: int | None + query_plan: Literal["empty", "trigram", "scan"] + issue_count: int = 0 + + +class DirectoryIndex: + """One serialized writer and isolated read transactions, without a RAM catalogue.""" + + def __init__(self, path: Path, *, source_kind: Literal["smb", "local"], source_identity: dict[str, str | int]) -> None: + self.path = path + self.source_kind = source_kind + self.source_identity = json.dumps(source_identity, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + self._writer_lock = threading.RLock() + self._reader_condition = threading.Condition(self._writer_lock) + self._query_slots = threading.BoundedSemaphore(QUERY_WORKERS) + self._active_queries = 0 + self._closing = False + self._writer: sqlite3.Connection | None = None + self._lease_fd: int | None = None + self._fts_ready = False + self._fts_repair_cursor: int | None = None + self._recovery_attempted = False + self._quarantine_restore_failed = False + self.page_cache_kib = PAGE_CACHE_KIB + self.growth_guard: Callable[[], AbstractContextManager[None]] = lambda: nullcontext() + + def configure_page_cache(self, value: int) -> None: + DIRECTORY_INDEX_CONTRACT.policy["page_cache_kib"].validate(value) + with self._writer_lock: + if self._writer is not None: + self._writer.execute(f"PRAGMA cache_size = -{value}") + self.page_cache_kib = value + + def _acquire_storage(self) -> None: + self._lease_fd = self.acquire_storage_lease(self.path) + + @staticmethod + def acquire_storage_lease(path: Path) -> int: + """Validate private storage and acquire ownership before open or cleanup.""" + namespace_descriptor = DirectoryIndex.acquire_namespace_lease(path.parent) + try: + DirectoryIndex._validate_storage_files(path) + return DirectoryIndex._acquire_file_lease(Path(str(path) + ".lease")) + finally: + os.close(namespace_descriptor) + + @staticmethod + def _validate_storage_files(path: Path) -> None: + for suffix in STORAGE_FILE_SUFFIXES: + candidate = Path(str(path) + suffix) + try: + info = candidate.lstat() + except FileNotFoundError: + continue + if not stat.S_ISREG(info.st_mode): + raise PermissionError("Directory-index files must be regular files without symbolic links. Correct the storage path.") + if os.name == "posix" and info.st_nlink != 1: + raise PermissionError( + "Directory-index files must not use hard links. Remove the alias or select new private cache storage, then retry." + ) + if os.name == "posix" and (info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o022): + raise PermissionError( + "Directory-index files must belong to the service account without public write access. Correct their permissions." + ) + + @staticmethod + def _clear_quarantine_owned(path: Path) -> bool: + with held_storage_directory(path.parent) as directory_descriptor: + DirectoryIndex._validate_storage_files(path) + removed = False + for suffix in STORAGE_FILE_SUFFIXES: + if suffix == ".lease": + continue + candidate = Path(str(path) + suffix) + validate_storage_binding(path.parent, directory_descriptor) + try: + if directory_descriptor is None: + candidate.unlink() + else: + os.unlink(candidate.name, dir_fd=directory_descriptor) + removed = True + except FileNotFoundError: + pass + return removed + + @staticmethod + def discard_quarantine(directory: Path) -> bool: + """Evict the single inactive rejected family under namespace ownership.""" + path = directory / QUARANTINE_FILE_NAME + if not any(Path(str(path) + suffix).exists() for suffix in STORAGE_FILE_SUFFIXES if suffix != ".lease"): + return False + namespace_descriptor = DirectoryIndex.acquire_namespace_lease(directory) + try: + path = directory / QUARANTINE_FILE_NAME + descriptor = DirectoryIndex._acquire_file_lease(Path(str(path) + ".lease")) + try: + return DirectoryIndex._clear_quarantine_owned(path) + finally: + os.close(descriptor) + finally: + os.close(namespace_descriptor) + + def _quarantine_owned(self) -> None: + if self._writer is not None or self._lease_fd is None or self._active_queries: + raise BlockingIOError("Directory recovery is still in use. Wait for source workers to close before retrying.") + self._quarantine_restore_failed = False + namespace_descriptor = self.acquire_namespace_lease(self.path.parent) + try: + path = self.path.parent / QUARANTINE_FILE_NAME + descriptor = self._acquire_file_lease(Path(str(path) + ".lease")) + try: + self._validate_storage_files(self.path) + self._clear_quarantine_owned(path) + moved: list[tuple[Path, Path]] = [] + with held_storage_directory(path.parent) as directory_descriptor: + + def move(original: Path, rejected: Path) -> None: + validate_storage_binding(path.parent, directory_descriptor) + if directory_descriptor is None: + os.replace(original, rejected) + else: + os.replace(original.name, rejected.name, src_dir_fd=directory_descriptor, dst_dir_fd=directory_descriptor) + + try: + for suffix in (*[suffix for suffix in STORAGE_FILE_SUFFIXES if suffix not in {"", ".lease"}], ""): + original = Path(str(self.path) + suffix) + rejected = Path(str(path) + suffix) + try: + move(original, rejected) + except FileNotFoundError: + continue + moved.append((original, rejected)) + except BaseException: + for original, rejected in reversed(moved): + try: + move(rejected, original) + except OSError as error: + self._quarantine_restore_failed = True + logger.warning( + "Directory recovery restore failed (%s). Reconnect after restoring private storage.", + type(error).__name__, + ) + raise + finally: + os.close(descriptor) + finally: + os.close(namespace_descriptor) + + @staticmethod + def acquire_namespace_lease(directory: Path) -> int: + """Serialize lease creation and inode cleanup across processes.""" + directory = directory.absolute() + validate_storage_ancestors(directory) + directory.mkdir(mode=PRIVATE_DIRECTORY_MODE, parents=True, exist_ok=True) + info = directory.stat() + if os.name == "posix" and (info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077): + raise PermissionError( + "Directory-index storage must belong to the service account with mode 0700. Correct its owner and permissions." + ) + return DirectoryIndex._acquire_file_lease(directory / NAMESPACE_LEASE_NAME) + + @staticmethod + def _acquire_file_lease(path: Path) -> int: + descriptor = os.open(path, os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), LEASE_FILE_MODE) + try: + info = os.fstat(descriptor) + if os.name == "posix" and info.st_nlink != 1: + raise PermissionError( + "Directory-index leases must not use hard links. Remove the alias or select new private cache storage, then retry." + ) + if not stat.S_ISREG(info.st_mode) or ( + os.name == "posix" and (info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o022) + ): + raise PermissionError("Directory-index lease is not a private regular file. Correct its owner and permissions.") + if os.name == "posix": + import fcntl + + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + elif sys.platform == "win32": + import msvcrt + + os.write(descriptor, b"\0") + os.lseek(descriptor, 0, os.SEEK_SET) + msvcrt.locking(descriptor, msvcrt.LK_NBLCK, 1) + else: + raise OSError("Directory-index process leases are unsupported on this platform. Use a supported backend host.") + except BaseException: + os.close(descriptor) + raise + return descriptor + + @staticmethod + def remove_orphan_lease(path: Path) -> bool: + """Hold namespace ownership until an unused source lease inode is removed.""" + namespace_descriptor = DirectoryIndex.acquire_namespace_lease(path.parent) + try: + with held_storage_directory(path.parent) as directory_descriptor: + descriptor = DirectoryIndex._acquire_file_lease(Path(str(path) + ".lease")) + try: + for suffix in STORAGE_FILE_SUFFIXES: + if suffix == ".lease": + continue + try: + Path(str(path) + suffix).lstat() + except FileNotFoundError: + continue + return False + finally: + os.close(descriptor) + validate_storage_binding(path.parent, directory_descriptor) + candidate = Path(str(path) + ".lease") + if directory_descriptor is None: + candidate.unlink() + else: + os.unlink(candidate.name, dir_fd=directory_descriptor) + return True + finally: + os.close(namespace_descriptor) + + def _release_storage(self) -> None: + if self._lease_fd is not None: + os.close(self._lease_fd) + self._lease_fd = None + + def _connect(self, *, readonly: bool = False) -> sqlite3.Connection: + connection = connect_directory_database(self.path, readonly=readonly) + try: + connection.execute("PRAGMA foreign_keys = ON") + connection.execute(f"PRAGMA cache_size = -{self.page_cache_kib}") + connection.execute("PRAGMA mmap_size = 0") + connection.execute("PRAGMA temp_store = FILE") + connection.execute("PRAGMA synchronous = FULL") + return connection + except BaseException: + connection.close() + raise + + def open(self, *, rebuild_invalid: bool = False) -> None: + """Validate storage and optionally replace disposable invalid data once.""" + with self._writer_lock: + if self._writer is not None: + return + validate_sqlite_runtime() + with self.growth_guard(): + self._acquire_storage() + try: + try: + self._open_owned() + except (DirectoryIndexRebuildRequired, sqlite3.DatabaseError) as error: + rebuildable = isinstance(error, DirectoryIndexRebuildRequired) or ( + getattr(error, "sqlite_errorcode", 0) & SQLITE_PRIMARY_ERROR_MASK + in {sqlite3.SQLITE_CORRUPT, sqlite3.SQLITE_NOTADB} + ) + if not rebuild_invalid or not rebuildable: + raise + if self._recovery_attempted: + raise RuntimeError( + "Directory recovery already failed after replacement. Restore private storage capacity and permissions, " + "then reconnect the source." + ) from error + try: + self._quarantine_owned() + except BaseException: + self._recovery_attempted = self._quarantine_restore_failed + raise + self._recovery_attempted = True + self._open_owned() + logger.debug("Directory storage recovered.", extra={"event": "directory_storage_recovered"}) + finally: + if self._writer is None: + self._release_storage() + + def _open_owned(self) -> None: + with self._writer_lock: + created = not self.path.exists() + if created: + descriptor = os.open(self.path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), LEASE_FILE_MODE) + os.close(descriptor) + with closing(self._connect()) as connection: + connection.executescript(SCHEMA_PATH.read_text(encoding="utf-8")) + connection.executescript(SCHEMA_PATH.with_name("fts.sql").read_text(encoding="utf-8")) + now = epoch_milliseconds() + with transaction(connection): + connection.execute("INSERT INTO generations(id, state, coverage) VALUES (1, 'building', 'partial')") + connection.execute( + "INSERT INTO source_metadata(id, source_kind, source_identity, normalization_version, " + "traversal_version, selected_generation, committed_at_ms, last_access_at_ms) " + "VALUES (1, ?, ?, ?, ?, 1, ?, ?)", + (self.source_kind, self.source_identity, NORMALIZATION_VERSION, TRAVERSAL_VERSION, now, now), + ) + connection = self._connect() + try: + if required_row(connection.execute("PRAGMA application_id"))[0] != APPLICATION_ID: + raise ValueError("Directory database application identifier is invalid. Rebuild this source index.") + if required_row(connection.execute("PRAGMA user_version"))[0] != SCHEMA_VERSION: + raise DirectoryIndexRebuildRequired("Directory database schema is unsupported. Rebuild this source index.") + placeholders = ",".join("?" for _ in REQUIRED_DIRECTORY_TABLES) + table_count = required_row( + connection.execute( + f"SELECT COUNT(*) FROM sqlite_schema WHERE type = 'table' AND name IN ({placeholders})", + REQUIRED_DIRECTORY_TABLES, + ) + )[0] + if table_count != len(REQUIRED_DIRECTORY_TABLES): + raise DirectoryIndexRebuildRequired("Directory database tables are missing. Rebuild this source index.") + metadata = connection.execute( + "SELECT source_kind, source_identity, normalization_version, traversal_version FROM source_metadata WHERE id = 1" + ).fetchone() + if metadata is None: + raise DirectoryIndexRebuildRequired("Directory database source metadata is missing. Rebuild this source index.") + if metadata[:2] != (self.source_kind, self.source_identity): + raise ValueError("Directory database source or policy does not match. Rebuild this source index.") + if metadata[2:] != (NORMALIZATION_VERSION, TRAVERSAL_VERSION): + raise DirectoryIndexRebuildRequired("Directory database source or policy does not match. Rebuild this source index.") + if connection.execute("PRAGMA quick_check").fetchone() != ("ok",): + raise DirectoryIndexRebuildRequired("Directory database integrity check failed. Rebuild this source index.") + if connection.execute("PRAGMA foreign_key_check").fetchone() is not None: + raise DirectoryIndexRebuildRequired("Directory database references are invalid. Rebuild this source index.") + if required_row(connection.execute(SCHEMA_PATH.with_name("validate-generations.sql").read_text(encoding="utf-8")))[0]: + raise DirectoryIndexRebuildRequired("Directory database generation metadata is invalid. Rebuild this source index.") + if connection.execute("PRAGMA journal_mode = WAL").fetchone() != ("wal",): + raise RuntimeError("Directory-index storage does not support WAL. Select supported private local storage.") + self._fts_ready = False + self._fts_repair_cursor = None + try: + self._prepare_fts(connection) + except sqlite3.DatabaseError as error: + if getattr(error, "sqlite_errorcode", 0) & SQLITE_PRIMARY_ERROR_MASK not in { + sqlite3.SQLITE_CORRUPT, + sqlite3.SQLITE_NOTADB, + }: + raise + raise RuntimeError( + "Directory search token repair could not start. Validated directory data was retained. " + "Restore private storage and retry the source." + ) from error + with transaction(connection): + connection.execute("UPDATE source_metadata SET last_access_at_ms = ? WHERE id = 1", (epoch_milliseconds(),)) + connection.execute( + "UPDATE generations SET state = 'retired' WHERE state = 'candidate' AND id <> " + "(SELECT selected_generation FROM source_metadata WHERE id = 1)" + ) + self._writer = connection + self._closing = False + logger.debug("Directory storage initialized.", extra={"event": "directory_storage_initialized"}) + except BaseException: + connection.close() + raise + + def _prepare_fts(self, connection: sqlite3.Connection) -> None: + connection.executescript(SCHEMA_PATH.with_name("fts.sql").read_text(encoding="utf-8")) + try: + with transaction(connection): + connection.execute("INSERT INTO directory_fts(directory_fts, rank) VALUES ('integrity-check', 1)") + self._fts_ready = True + except sqlite3.DatabaseError as error: + if getattr(error, "sqlite_errorcode", 0) & SQLITE_PRIMARY_ERROR_MASK != sqlite3.SQLITE_CORRUPT: + raise + with transaction(connection): + connection.execute("INSERT INTO directory_fts(directory_fts) VALUES ('delete-all')") + + def _connection(self) -> sqlite3.Connection: + if self._writer is None or self._closing: + raise RuntimeError("Directory index is not initialized. Wait for source initialization and retry.") + return self._writer + + @property + def has_active_queries(self) -> bool: + with self._reader_condition: + return self._active_queries > 0 + + @property + def needs_fts_repair(self) -> bool: + return not self._fts_ready + + def _require_fts(self) -> None: + if not self._fts_ready: + raise RuntimeError("Directory search index is being rebuilt. Existing rows remain searchable; retry updates after repair.") + + def repair_fts_batch(self) -> bool: + """Rebuild derived tokens without changing directory rows or freshness.""" + with self._writer_lock, self.growth_guard(): + if self._fts_ready: + return False + connection = self._connection() + with transaction(connection): + rows = connection.execute( + "SELECT id, path_key FROM directories WHERE ? IS NULL OR id > ? ORDER BY id LIMIT ?", + (self._fts_repair_cursor, self._fts_repair_cursor, ROW_BATCH_SIZE), + ).fetchall() + connection.executemany("INSERT INTO directory_fts(rowid, path_key) VALUES (?, ?)", rows) + complete = len(rows) < ROW_BATCH_SIZE + if complete: + connection.execute("INSERT INTO directory_fts(directory_fts, rank) VALUES ('integrity-check', 1)") + if rows: + last_row_id = rows[-1][0] + if not isinstance(last_row_id, int): + raise ValueError("Directory row identifier is invalid. Rebuild this source index.") + self._fts_repair_cursor = last_row_id + self._fts_ready = complete + return not complete + + def close(self) -> None: + with self._reader_condition: + self._closing = True + self._reader_condition.wait_for(lambda: self._active_queries == 0) + if self._writer is not None: + self._writer.close() + self._writer = None + self._release_storage() + + def _revision(self, connection: sqlite3.Connection) -> None: + revision = required_row(connection.execute("SELECT accepted_revision FROM source_metadata WHERE id = 1"))[0] + if revision >= MAX_SIGNED_INTEGER: + raise OverflowError("Directory-index revision is exhausted. Rebuild this source index.") + connection.execute( + "UPDATE source_metadata SET accepted_revision = accepted_revision + 1, committed_at_ms = ? WHERE id = 1", + (epoch_milliseconds(),), + ) + + def write_paths(self, paths: Iterable[str], *, generation_id: int | None = None) -> None: + iterator = iter(paths) + pending: str | None = None + while True: + batch: list[tuple[str, str, str, int, bool]] = [] + batch_bytes = 0 + for _ in range(ROW_BATCH_SIZE): + path = pending if pending is not None else next(iterator, None) + pending = None + if path is None: + break + validate_directory_path(path) + path_bytes = len(path.encode("utf-8")) + if path_bytes > SCAN_BATCH_BYTES: + raise ValueError("Directory path exceeds the scan-batch byte limit. Source coverage is incomplete.") + if batch and batch_bytes + path_bytes > SCAN_BATCH_BYTES: + pending = path + break + key = normalize_search_text(path) + batch.append( + ( + path, + key, + normalize_search_text(path.rsplit("/", 1)[-1]), + path.count("/"), + any(component.startswith(".") for component in path.split("/")), + ) + ) + batch_bytes += path_bytes + if not batch: + return + with self._writer_lock, self.growth_guard(): + connection = self._connection() + self._require_fts() + with transaction(connection): + selected = required_row(connection.execute("SELECT selected_generation FROM source_metadata WHERE id = 1"))[0] + target = selected if generation_id is None else generation_id + if generation_id is not None: + state = connection.execute("SELECT state FROM generations WHERE id = ?", (target,)).fetchone() + if state is None or state[0] not in {"building", "candidate"}: + raise ValueError("Scan generation is no longer active. Discard this batch and request verification.") + connection.executemany( + "INSERT INTO directories(generation_id, path, path_key, basename_key, depth, has_dot_directory) " + "VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(generation_id, path) DO NOTHING", + ((target, *row) for row in batch), + ) + if target == selected: + self._revision(connection) + + def begin_verification(self) -> int: + with self._writer_lock, self.growth_guard(): + connection = self._connection() + with transaction(connection): + row = connection.execute("INSERT INTO generations(state, coverage) VALUES ('candidate', 'partial') RETURNING id").fetchone() + if row is None: + raise RuntimeError("SQLite did not allocate a verification generation. Retry verification.") + return int(row[0]) + raise RuntimeError("Verification generation allocation did not complete. Retry verification.") + + def status(self) -> DirectoryIndexSearch: + return self.search("") + + def has_resumable_scan(self) -> bool: + """Return whether an issue-free candidate has completed work to retain.""" + with self._writer_lock: + return bool( + required_row( + self._connection().execute("SELECT EXISTS(SELECT 1 FROM generations WHERE state = 'candidate' AND issue_count = 0)") + )[0] + ) + + def prepare_scan(self) -> tuple[int, bool]: + """Resume issue-free verification work without trusting the selected rows.""" + with self._writer_lock, self.growth_guard(): + connection = self._connection() + self._require_fts() + with transaction(connection): + retained = connection.execute( + "SELECT id FROM generations WHERE state = 'candidate' AND issue_count = 0 ORDER BY id DESC LIMIT 1" + ).fetchone() + if retained is not None: + return int(retained[0]), False + connection.execute("UPDATE generations SET state = 'retired' WHERE state = 'candidate'") + generation, state, count, queued, issues = required_row( + connection.execute( + "SELECT g.id, g.state, g.indexed_count, EXISTS(SELECT 1 FROM scan_queue q WHERE q.generation_id = g.id), g.issue_count " + "FROM generations g JOIN source_metadata m ON m.selected_generation = g.id WHERE m.id = 1" + ) + ) + if ( + state == "building" + and not count + and not queued + and ( + not issues + or not required_row( + connection.execute( + "SELECT EXISTS(SELECT 1 FROM coverage_issues WHERE generation_id = ? AND code <> 'event_loss')", + (generation,), + ) + )[0] + ) + ): + if issues: + connection.execute("DELETE FROM coverage_issues WHERE generation_id = ? AND code = 'event_loss'", (generation,)) + connection.execute("UPDATE generations SET coverage = 'partial', issue_count = 0 WHERE id = ?", (generation,)) + self._revision(connection) + return int(generation), True + candidate = required_row( + connection.execute("INSERT INTO generations(state, coverage) VALUES ('candidate', 'partial') RETURNING id") + ) + return int(candidate[0]), False + raise RuntimeError("Directory scan allocation did not complete. Retry source verification.") + + def seed_scan(self, generation_id: int) -> None: + with self._writer_lock: + connection = self._connection() + with transaction(connection): + connection.execute("INSERT INTO scan_queue(generation_id, path) VALUES (?, '') ON CONFLICT DO NOTHING", (generation_id,)) + + def pending_scan_paths(self, generation_id: int, limit: int = ROW_BATCH_SIZE) -> list[str]: + if not 1 <= limit <= ROW_BATCH_SIZE: + raise ValueError("Scan work batch limit is outside the supported range.") + with self._writer_lock: + return [ + row[0] + for row in self._connection().execute( + "SELECT path FROM scan_queue WHERE generation_id = ? AND processed = 0 ORDER BY rowid LIMIT ?", (generation_id, limit) + ) + ] + + def write_scan_children(self, generation_id: int, children: Iterable[tuple[str, bool]]) -> None: + self._write_children(generation_id, children) + + def requeue_completed_scan_batch(self, generation_id: int) -> bool: + """Revalidate retained work after an observation gap without discarding rows.""" + with self._writer_lock, self.growth_guard(): + connection = self._connection() + with transaction(connection): + state = required_row(connection.execute("SELECT state FROM generations WHERE id = ?", (generation_id,)))[0] + if state not in ("building", "candidate"): + raise ValueError("Scan generation is no longer active. Request source verification.") + changed = connection.execute( + "UPDATE scan_queue SET processed = 0 WHERE rowid IN " + "(SELECT rowid FROM scan_queue WHERE generation_id = ? AND processed = 1 LIMIT ?)", + (generation_id, ROW_BATCH_SIZE), + ).rowcount + return changed == ROW_BATCH_SIZE + raise RuntimeError("Scan revalidation did not complete. Retry source verification.") + + def accepted_generation(self) -> int | None: + with self._writer_lock: + row = ( + self._connection() + .execute("SELECT g.id FROM generations g JOIN source_metadata m ON m.selected_generation = g.id WHERE g.state = 'accepted'") + .fetchone() + ) + return None if row is None else int(row[0]) + + def reconciliation_generation(self) -> int | None: + with self._writer_lock: + row = ( + self._connection() + .execute( + "SELECT g.id FROM generations g JOIN source_metadata m ON m.selected_generation = g.id WHERE g.state = 'accepted' " + "AND EXISTS(SELECT 1 FROM scan_queue q WHERE q.generation_id = g.id AND q.processed = 0)" + ) + .fetchone() + ) + return None if row is None else int(row[0]) + + @staticmethod + def _require_reconciliation_parent(connection: sqlite3.Connection, generation_id: int, parent: str | None) -> None: + accepted = required_row( + connection.execute( + "SELECT EXISTS(SELECT 1 FROM generations g JOIN source_metadata m ON m.selected_generation = g.id " + "WHERE g.id = ? AND g.state = 'accepted' AND (? IS NULL OR EXISTS(SELECT 1 FROM scan_queue q " + "WHERE q.generation_id = g.id AND q.path = ? AND q.processed = 0)))", + (generation_id, parent, parent), + ) + )[0] + if not accepted: + if parent is None: + raise ValueError("Reconciliation generation is no longer selected and accepted. Request source verification.") + raise ValueError("Reconciliation parent is not pending on the selected accepted generation. Request source verification.") + + def queue_reconciliation_parent(self, generation_id: int, parent: str) -> None: + """Queue an accepted parent without changing selected rows or verification time.""" + if parent: + validate_directory_path(parent) + with self._writer_lock, self.growth_guard(): + connection = self._connection() + with transaction(connection): + self._require_reconciliation_parent(connection, generation_id, None) + connection.execute( + "INSERT INTO scan_queue(generation_id, path) VALUES (?, ?) " + "ON CONFLICT(generation_id, path) DO UPDATE SET processed = 0", + (generation_id, parent), + ) + + def queue_observed_parent(self, parent: str) -> None: + """Queue a source hint across active scans and the selected accepted generation.""" + if parent: + validate_directory_path(parent) + with self._writer_lock, self.growth_guard(): + connection = self._connection() + with transaction(connection): + connection.execute( + "INSERT INTO scan_queue(generation_id, path) SELECT g.id, ? FROM generations g " + "WHERE g.state IN ('building', 'candidate') OR (g.state = 'accepted' AND g.id = " + "(SELECT selected_generation FROM source_metadata WHERE id = 1)) " + "ON CONFLICT(generation_id, path) DO UPDATE SET processed = 0", + (parent,), + ) + + def finish_reconciliation_parent(self, generation_id: int, parent: str) -> None: + with self._writer_lock, self.growth_guard(): + connection = self._connection() + with transaction(connection): + self._require_reconciliation_parent(connection, generation_id, parent) + connection.execute("UPDATE scan_queue SET processed = 1 WHERE generation_id = ? AND path = ?", (generation_id, parent)) + + def write_reconciliation_children(self, generation_id: int, parent: str, children: Iterable[tuple[str, bool]]) -> None: + """Commit immediate children only for pending selected accepted work.""" + if parent: + validate_directory_path(parent) + self._write_children(generation_id, children, parent=parent) + + def _write_children(self, generation_id: int, children: Iterable[tuple[str, bool]], *, parent: str | None = None) -> None: + batch: list[tuple[str, bool]] = [] + batch_bytes = 0 + for path, descend in islice(children, ROW_BATCH_SIZE + 1): + if parent is not None: + path_bytes = len(path.encode("utf-8")) + if path_bytes > SCAN_BATCH_BYTES - batch_bytes: + raise ValueError("Reconciliation children exceed the scan-batch byte limit. Split them into bounded batches.") + batch_bytes += path_bytes + batch.append((path, descend)) + if len(batch) > ROW_BATCH_SIZE: + raise ValueError("Scan row batch exceeds the supported limit. Split the batch.") + for path, _ in batch: + validate_directory_path(path) + if parent is not None and path.rpartition("/")[0] != parent: + raise ValueError("Reconciliation child is outside its immediate parent. Request source verification.") + with self._writer_lock, self.growth_guard(): + connection = self._connection() + self._require_fts() + with transaction(connection): + if parent is None: + state = connection.execute("SELECT state FROM generations WHERE id = ?", (generation_id,)).fetchone() + if state is None or state[0] not in {"building", "candidate"}: + raise ValueError("Scan generation is no longer active. Discard this batch and request verification.") + else: + self._require_reconciliation_parent(connection, generation_id, parent) + changed = False + for path, descend in batch: + inserted = connection.execute( + "INSERT INTO directories(generation_id, path, path_key, basename_key, depth, has_dot_directory) " + "VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(generation_id, path) DO NOTHING", + ( + generation_id, + path, + normalize_search_text(path), + normalize_search_text(path.rsplit("/", 1)[-1]), + path.count("/"), + any(part.startswith(".") for part in path.split("/")), + ), + ).rowcount + changed |= bool(inserted) + if descend and (parent is None or inserted): + connection.execute( + "INSERT INTO scan_queue(generation_id, path) VALUES (?, ?) ON CONFLICT DO NOTHING", + (generation_id, path), + ) + if (parent is None or changed) and required_row( + connection.execute("SELECT selected_generation FROM source_metadata WHERE id = 1") + )[0] == generation_id: + self._revision(connection) + + def direct_child_paths( + self, generation_id: int, parent: str, *, after_path: str | None = None, maximum_bytes: int = SCAN_BATCH_BYTES + ) -> list[str]: + """Page original immediate-child paths, including branches without an ancestor row.""" + if parent: + validate_directory_path(parent) + if after_path is not None: + validate_directory_path(after_path) + if after_path.rpartition("/")[0] != parent: + raise ValueError("Child-page cursor must belong to the requested parent.") + if type(maximum_bytes) is not int or not 1 <= maximum_bytes <= SCAN_BATCH_BYTES: + raise ValueError("Child-page byte budget is outside the supported scan-batch range.") + prefix = parent + "/" if parent else "" + with self._writer_lock: + result: list[str] = [] + size = 0 + overflow: str | None = None + frontier: str | None = None + upper = prefix[:-1] + "0" if prefix else None + sql = "SELECT path FROM directories WHERE generation_id = ? AND path >= ?" + parameters: list[str | int] = [generation_id, max(prefix, after_path or "")] + if upper is not None: + sql += " AND path < ?" + parameters.append(upper) + sql += " ORDER BY path" + with closing(self._connection().execute(sql, parameters)) as cursor: + for (stored_path,) in cursor: + if frontier is not None and stored_path >= frontier: + break + path = prefix + stored_path[len(prefix) :].split("/", 1)[0] + if (after_path is not None and path <= after_path) or (overflow is not None and path >= overflow): + continue + if path in result: + continue + insort(result, path) + size += len(path.encode("utf-8")) + while len(result) > ROW_BATCH_SIZE or size > maximum_bytes: + overflow = result.pop() + size -= len(overflow.encode("utf-8")) + ceiling = overflow if overflow is not None else result[-1] if len(result) == ROW_BATCH_SIZE else None + if ceiling is not None: + name = ceiling[len(prefix) :] + # A missing shorter ancestor can sort after punctuation-bearing siblings in stored paths. + cut = next((offset for offset, character in enumerate(name) if offset and character < "/"), len(name)) + frontier = prefix + name[:cut] + "0" + if overflow is not None and len(result) < ROW_BATCH_SIZE and len(overflow.encode("utf-8")) > maximum_bytes: + raise ValueError("Directory child exceeds the scan-page byte budget. Source coverage is incomplete.") + return result + + def prune_subtree_batch(self, generation_id: int, path: str) -> bool: + """Remove bounded missing-subtree work; return whether more work remains.""" + validate_directory_path(path) + prefix = path + "/" + with self._writer_lock, self.growth_guard(): + connection = self._connection() + self._require_fts() + with transaction(connection): + state = connection.execute("SELECT state FROM generations WHERE id = ?", (generation_id,)).fetchone() + if state is None or state[0] not in {"building", "candidate", "accepted"}: + raise ValueError("Reconciliation generation is no longer active. Discard this observation and request verification.") + removed = connection.execute( + "DELETE FROM directories WHERE id IN (SELECT id FROM directories WHERE generation_id = ? " + "AND (path = ? OR substr(path, 1, length(?)) = ?) ORDER BY id LIMIT ?)", + (generation_id, path, prefix, prefix, ROW_BATCH_SIZE), + ).rowcount + queued = connection.execute( + "DELETE FROM scan_queue WHERE generation_id = ? AND path IN (SELECT path FROM scan_queue " + "WHERE generation_id = ? AND (path = ? OR substr(path, 1, length(?)) = ?) ORDER BY path LIMIT ?)", + (generation_id, generation_id, path, prefix, prefix, ROW_BATCH_SIZE), + ).rowcount + if (removed or queued) and required_row(connection.execute("SELECT selected_generation FROM source_metadata WHERE id = 1"))[ + 0 + ] == generation_id: + self._revision(connection) + return bool( + required_row( + connection.execute( + "SELECT EXISTS(SELECT 1 FROM directories WHERE generation_id = ? " + "AND (path = ? OR substr(path, 1, length(?)) = ?)) OR EXISTS(SELECT 1 FROM scan_queue " + "WHERE generation_id = ? AND (path = ? OR substr(path, 1, length(?)) = ?))", + (generation_id, path, prefix, prefix, generation_id, path, prefix, prefix), + ) + )[0] + ) + raise RuntimeError("Directory reconciliation did not complete. Retry source verification.") + + def record_unsafe_traversal(self, generation_id: int) -> None: + """Record an excluded directory without selecting an incomplete candidate.""" + self._record_scan_issue(generation_id, "unsafe_traversal") + + def record_access_denied(self, generation_id: int) -> None: + """Record an unreadable descendant without discarding readable siblings.""" + self._record_scan_issue(generation_id, "access_denied") + + def _record_scan_issue(self, generation_id: int, code: Literal["unsafe_traversal", "access_denied"]) -> None: + with self._writer_lock, self.growth_guard(): + connection = self._connection() + with transaction(connection): + state = connection.execute("SELECT state FROM generations WHERE id = ?", (generation_id,)).fetchone() + if state is None or state[0] not in {"building", "candidate"}: + raise ValueError("Traversal issue belongs to an inactive scan. Restart source verification.") + connection.execute( + "INSERT INTO coverage_issues(generation_id, code, count) VALUES (?, ?, 1) " + "ON CONFLICT(generation_id, code) DO UPDATE SET count = count + 1", + (generation_id, code), + ) + connection.execute( + "UPDATE generations SET issue_count = (SELECT sum(count) FROM coverage_issues WHERE generation_id = ?), " + "coverage = CASE WHEN coverage = 'uncertain' THEN 'uncertain' ELSE 'partial' END WHERE id = ?", + (generation_id, generation_id), + ) + if required_row(connection.execute("SELECT selected_generation FROM source_metadata WHERE id = 1"))[0] == generation_id: + self._revision(connection) + + def finish_scan_parent(self, generation_id: int, path: str) -> None: + with self._writer_lock: + connection = self._connection() + with transaction(connection): + connection.execute("UPDATE scan_queue SET processed = 1 WHERE generation_id = ? AND path = ?", (generation_id, path)) + + def discard_generation(self, generation_id: int) -> None: + with self._writer_lock: + connection = self._connection() + with transaction(connection): + connection.execute( + "UPDATE generations SET state = 'retired' WHERE id = ? AND id <> (SELECT selected_generation FROM source_metadata WHERE id = 1)", + (generation_id,), + ) + + def checkpoint(self) -> None: + with self._writer_lock: + self._connection().execute("PRAGMA wal_checkpoint(PASSIVE)") + logger.debug("Directory checkpoint completed.", extra={"event": "directory_checkpoint_completed"}) + + def checkpoint_if_due(self) -> bool: + """Checkpoint only a large WAL; pinned readers retain retry eligibility.""" + with self._writer_lock: + if self.has_active_queries: + return False + try: + metadata = Path(str(self.path) + "-wal").lstat() + except FileNotFoundError: + return True + if not stat.S_ISREG(metadata.st_mode): + raise PermissionError("Directory-index WAL must be a regular file. Correct private cache storage and retry.") + if metadata.st_size < CHECKPOINT_WAL_BYTES: + return True + with self.growth_guard(): + connection = self._connection() + busy, total, completed = required_row(connection.execute("PRAGMA wal_checkpoint(PASSIVE)")) + if busy or total != completed: + logger.debug("Directory checkpoint deferred for readers.", extra={"event": "directory_checkpoint_deferred"}) + return False + busy, total, completed = required_row(connection.execute("PRAGMA wal_checkpoint(TRUNCATE)")) + return not busy and total == completed + + def touch_access(self, accessed_at_ms: int) -> None: + if type(accessed_at_ms) is not int or not 0 <= accessed_at_ms <= MAX_SIGNED_INTEGER: + raise ValueError("Directory-index access time is invalid. Check the host clock and retry.") + with self._writer_lock, self.growth_guard(): + self._connection().execute("UPDATE source_metadata SET last_access_at_ms = ? WHERE id = 1", (accessed_at_ms,)) + + def reclaim_pages(self) -> bool: + """Run one admitted reclamation step; false means readers deferred it.""" + with self._writer_lock, self.growth_guard(): + if self._active_queries: + return False + connection = self._connection() + if required_row(connection.execute("PRAGMA auto_vacuum"))[0] != INCREMENTAL_VACUUM_MODE: + raise ValueError("Directory storage does not support incremental reclamation. Rebuild this source index.") + if required_row(connection.execute("PRAGMA freelist_count"))[0] == 0: + return True + busy, total, completed = required_row(connection.execute("PRAGMA wal_checkpoint(PASSIVE)")) + if busy or total != completed: + return False + connection.execute(f"PRAGMA incremental_vacuum({MAINTENANCE_PAGE_BATCH})").fetchall() + logger.debug("Directory page reclamation completed.", extra={"event": "directory_pages_reclaimed"}) + busy, total, completed = required_row(connection.execute("PRAGMA wal_checkpoint(TRUNCATE)")) + return not busy and total == completed + + def cleanup_retired_batch(self) -> bool: + """Remove bounded retired rows; return whether logical cleanup remains.""" + with self._writer_lock, self.growth_guard(): + self._require_fts() + connection = self._connection() + if self._active_queries: + return True + with transaction(connection): + retired = connection.execute( + "SELECT id FROM generations WHERE state = 'retired' AND id <> " + "(SELECT selected_generation FROM source_metadata WHERE id = 1) ORDER BY id LIMIT 1" + ).fetchone() + if retired is None: + return False + generation = retired[0] + connection.execute( + "DELETE FROM directories WHERE id IN (SELECT id FROM directories WHERE generation_id = ? ORDER BY id LIMIT ?)", + (generation, ROW_BATCH_SIZE), + ) + connection.execute( + "DELETE FROM scan_queue WHERE generation_id = ? AND path IN " + "(SELECT path FROM scan_queue WHERE generation_id = ? ORDER BY path LIMIT ?)", + (generation, generation, ROW_BATCH_SIZE), + ) + connection.execute( + "DELETE FROM generations WHERE id = ? AND NOT EXISTS " + "(SELECT 1 FROM directories WHERE generation_id = ?) AND NOT EXISTS " + "(SELECT 1 FROM scan_queue WHERE generation_id = ?)", + (generation, generation, generation), + ) + return connection.execute("SELECT 1 FROM generations WHERE state = 'retired' LIMIT 1").fetchone() is not None + raise RuntimeError("Directory cleanup did not complete. Retry background cleanup.") + + def mark_uncertain(self) -> None: + """Persist event loss without removing rows or advancing verification time.""" + with self._writer_lock: + connection = self._connection() + with transaction(connection): + generation = required_row(connection.execute("SELECT selected_generation FROM source_metadata WHERE id = 1"))[0] + connection.execute( + "INSERT INTO coverage_issues(generation_id, code, count) VALUES (?, 'event_loss', 1) " + "ON CONFLICT(generation_id, code) DO NOTHING", + (generation,), + ) + changed = connection.execute( + "UPDATE generations SET coverage = 'uncertain', issue_count = " + "(SELECT sum(count) FROM coverage_issues WHERE generation_id = ?) WHERE id = ? " + "AND (coverage <> 'uncertain' OR issue_count <> (SELECT sum(count) FROM coverage_issues WHERE generation_id = ?))", + (generation, generation, generation), + ).rowcount + if changed: + self._revision(connection) + + def mutate_path(self, action: Literal["add", "remove", "rename"], path: str, new_path: str | None = None) -> None: + """Apply one observation to both accepted rows and active verification rows.""" + self._mutate_path(action, path, new_path, reconcile=False) + + def mutate_observed_path(self, action: Literal["add", "remove", "rename"], path: str, new_path: str | None = None) -> None: + """Commit manager-owned observations and accepted-parent work together.""" + self._mutate_path(action, path, new_path, reconcile=True) + + def _mutate_path(self, action: Literal["add", "remove", "rename"], path: str, new_path: str | None, *, reconcile: bool) -> None: + validate_directory_path(path) + if action == "rename": + if new_path is None: + raise ValueError("A directory rename requires a new relative path.") + validate_directory_path(new_path) + if new_path == path: + return + if new_path.startswith(path + "/"): + raise ValueError("A directory cannot be renamed into its own subtree.") + with self._writer_lock, self.growth_guard(): + connection = self._connection() + self._require_fts() + connection.create_function("normalize_directory_key", 1, sql_normalize_key, deterministic=True) + connection.create_function("directory_basename_key", 1, sql_basename_key, deterministic=True) + connection.create_function("directory_dot_path", 1, sql_dot_path, deterministic=True) + with transaction(connection): + generations = connection.execute( + "SELECT id FROM generations WHERE state IN ('building', 'accepted', 'candidate')" + ).fetchall() + for (generation_id,) in generations: + if action == "add": + connection.execute( + "INSERT INTO directories(generation_id, path, path_key, basename_key, depth, has_dot_directory) " + "VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(generation_id, path) DO NOTHING", + ( + generation_id, + path, + normalize_search_text(path), + normalize_search_text(path.rsplit("/", 1)[-1]), + path.count("/"), + any(part.startswith(".") for part in path.split("/")), + ), + ) + connection.execute( + "INSERT INTO scan_queue(generation_id, path) SELECT id, ? FROM generations " + "WHERE id = ? AND state IN ('building', 'candidate') " + "ON CONFLICT(generation_id, path) DO UPDATE SET processed = 0", + (path, generation_id), + ) + elif action == "remove": + connection.execute( + "DELETE FROM directories WHERE generation_id = ? AND (path = ? OR substr(path, 1, ?) = ?)", + (generation_id, path, len(path) + 1, path + "/"), + ) + connection.execute( + "DELETE FROM scan_queue WHERE generation_id = ? AND (path = ? OR substr(path, 1, ?) = ?)", + (generation_id, path, len(path) + 1, path + "/"), + ) + elif action == "rename" and new_path is not None: + expression = "? || substr(path, ?)" + connection.execute( + f"UPDATE directories SET path = {expression}, path_key = normalize_directory_key({expression}), " + f"basename_key = directory_basename_key({expression}), has_dot_directory = directory_dot_path({expression}), " + "depth = depth + ? WHERE generation_id = ? AND (path = ? OR substr(path, 1, ?) = ?)", + ( + new_path, + len(path) + 1, + new_path, + len(path) + 1, + new_path, + len(path) + 1, + new_path, + len(path) + 1, + new_path.count("/") - path.count("/"), + generation_id, + path, + len(path) + 1, + path + "/", + ), + ) + connection.execute( + "UPDATE scan_queue SET path = ? || substr(path, ?), processed = 0 " + "WHERE generation_id = ? AND (path = ? OR substr(path, 1, ?) = ?)", + (new_path, len(path) + 1, generation_id, path, len(path) + 1, path + "/"), + ) + else: + raise ValueError("Unsupported directory mutation. Use add, remove, or rename.") + if reconcile: + accepted = connection.execute( + "SELECT g.id FROM generations g JOIN source_metadata m ON m.selected_generation = g.id WHERE g.state = 'accepted'" + ).fetchone() + if accepted is not None: + parents = {path.rpartition("/")[0]} + if action == "add": + parents.add(path) + elif action == "rename" and new_path is not None: + parents.add(new_path.rpartition("/")[0]) + if ( + connection.execute( + "SELECT 1 FROM directories WHERE generation_id = ? AND path = ?", (accepted[0], new_path) + ).fetchone() + is not None + ): + parents.add(new_path) + connection.executemany( + "INSERT INTO scan_queue(generation_id, path) VALUES (?, ?) " + "ON CONFLICT(generation_id, path) DO UPDATE SET processed = 0", + ((accepted[0], parent) for parent in sorted(parents)), + ) + self._revision(connection) + + def finish_generation(self, generation_id: int, *, issue_count: int = 0) -> bool: + """Only issue-free full passes select candidates and advance verification time.""" + if not 0 <= issue_count <= MAX_SIGNED_INTEGER: + raise ValueError("Coverage issue count is outside the supported range.") + with self._writer_lock: + connection = self._connection() + with transaction(connection): + row = connection.execute("SELECT state FROM generations WHERE id = ?", (generation_id,)).fetchone() + if row is None or row[0] not in {"candidate", "building"}: + raise ValueError("Generation is not an active build or verification candidate.") + stored_issues = required_row( + connection.execute("SELECT coalesce(sum(count), 0) FROM coverage_issues WHERE generation_id = ?", (generation_id,)) + )[0] + additional_issues = max(0, issue_count - stored_issues) + if additional_issues: + connection.execute( + "INSERT INTO coverage_issues(generation_id, code, count) VALUES (?, 'scan_failed', ?) " + "ON CONFLICT(generation_id, code) DO UPDATE SET count = count + excluded.count", + (generation_id, additional_issues), + ) + unresolved = max(issue_count, stored_issues) + if unresolved: + connection.execute( + "UPDATE generations SET issue_count = ?, coverage = CASE WHEN coverage = 'uncertain' THEN 'uncertain' ELSE 'partial' END WHERE id = ?", + (unresolved, generation_id), + ) + selected = required_row(connection.execute("SELECT selected_generation FROM source_metadata WHERE id = 1"))[0] + if selected == generation_id: + self._revision(connection) + logger.debug("Directory scan issues observed.", extra={"event": "directory_scan_issues", "issue_count": unresolved}) + return False + if connection.execute( + "SELECT 1 FROM scan_queue WHERE generation_id = ? AND processed = 0 LIMIT 1", (generation_id,) + ).fetchone(): + raise ValueError("Scan work remains queued. Complete the scan before selecting this generation.") + previous = required_row(connection.execute("SELECT selected_generation FROM source_metadata WHERE id = 1"))[0] + connection.execute("UPDATE generations SET state = 'retired' WHERE id = ? AND id <> ?", (previous, generation_id)) + connection.execute( + "UPDATE generations SET state = 'accepted', coverage = 'complete', issue_count = 0 WHERE id = ?", (generation_id,) + ) + connection.execute( + "UPDATE source_metadata SET selected_generation = ?, last_verified_at_ms = ? WHERE id = 1", + (generation_id, epoch_milliseconds()), + ) + self._revision(connection) + logger.debug("Directory generation accepted.", extra={"event": "directory_generation_accepted"}) + return True + + def search( + self, + query: str, + *, + limit: int = MAX_RESULT_LIMIT, + include_dot_directories: bool = False, + cancelled: threading.Event | None = None, + force_scan: bool = False, + ) -> DirectoryIndexSearch: + if not self._query_slots.acquire(timeout=SQL_BUSY_TIMEOUT_MS / 1000): + raise TimeoutError("Directory query workers are busy. Wait briefly and retry the search.") + admitted = False + try: + with self._reader_condition: + self._connection() + self._active_queries += 1 + admitted = True + return self._search( + query, limit=limit, include_dot_directories=include_dot_directories, cancelled=cancelled, force_scan=force_scan + ) + except sqlite3.OperationalError as exc: + if exc.sqlite_errorcode == sqlite3.SQLITE_INTERRUPT: + raise InterruptedError("Directory search was cancelled or timed out. Retry the search.") from exc + raise + finally: + with self._reader_condition: + if admitted: + self._active_queries -= 1 + self._reader_condition.notify_all() + self._query_slots.release() + + def _search( + self, + query: str, + *, + limit: int = MAX_RESULT_LIMIT, + include_dot_directories: bool = False, + cancelled: threading.Event | None = None, + force_scan: bool = False, + ) -> DirectoryIndexSearch: + terms = search_terms(query) + if not 1 <= limit <= MAX_RESULT_LIMIT: + raise ValueError(f"Result limit must be between 1 and {MAX_RESULT_LIMIT}.") + deadline = time.monotonic() + QUERY_TIMEOUT_SECONDS + + def interrupted() -> bool: + return bool(cancelled is not None and cancelled.is_set()) or time.monotonic() >= deadline + + if interrupted(): + raise InterruptedError("Directory search was cancelled or timed out. Retry the search.") + fts_ready = self._fts_ready + with closing(self._connect(readonly=True)) as connection: + connection.set_progress_handler(interrupted, SQL_PROGRESS_STEPS) + with transaction(connection): + metadata = required_row( + connection.execute( + "SELECT selected_generation, accepted_revision, last_verified_at_ms FROM source_metadata WHERE id = 1" + ) + ) + generation_id, revision, verified = metadata + directory_count, coverage, issue_count = required_row( + connection.execute("SELECT indexed_count, coverage, issue_count FROM generations WHERE id = ?", (generation_id,)) + ) + plan: Literal["empty", "trigram", "scan"] = "empty" + count = 0 + if not terms: + return DirectoryIndexSearch((), count, directory_count, revision, coverage, verified, plan, issue_count) + plan = "trigram" if fts_ready and not force_scan and all(len(term) >= 3 for term in terms) else "scan" + columns = "SELECT directories.path, directories.path_key, directories.basename_key, directories.depth " + sql = columns + ( + "FROM directory_fts CROSS JOIN directories WHERE directories.id = directory_fts.rowid AND directories.generation_id = ?" + if plan == "trigram" + else "FROM directories WHERE directories.generation_id = ?" + ) + parameters: list[str | int] = [generation_id] + if not include_dot_directories: + sql += " AND directories.has_dot_directory = 0" + if plan == "trigram": + sql += " AND directory_fts MATCH ?" + parameters.append(" OR ".join('"' + term.replace('"', '""') + '"' for term in terms)) + + def matches() -> Iterable[DirectoryIndexMatch]: + nonlocal count + with closing(connection.execute(sql, parameters)) as cursor: + for path, path_key, basename_key, depth in cursor: + if interrupted(): + raise InterruptedError("Directory search was cancelled or timed out. Retry the search.") + matched = tuple(term for term in terms if term in path_key) + if matched: + count += 1 + yield DirectoryIndexMatch( + path, len(matched), min(single_term_rank(basename_key, term) for term in matched), depth, path_key + ) + + rows = tuple(heapq.nsmallest(limit, matches(), key=lambda result: result.sort_key)) + return DirectoryIndexSearch(rows, count, directory_count, revision, coverage, verified, plan, issue_count) + raise RuntimeError("Directory search did not complete. Retry the search.") diff --git a/backend/app/services/directory_index_resources.py b/backend/app/services/directory_index_resources.py new file mode 100644 index 000000000..e542a87ba --- /dev/null +++ b/backend/app/services/directory_index_resources.py @@ -0,0 +1,367 @@ +"""Manager-wide disk accounting and admission for disposable directory indexes.""" + +from __future__ import annotations + +import heapq +import logging +import os +import re +import shutil +import sqlite3 +import stat +import threading +import time +from contextlib import closing, contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Iterator +from uuid import UUID + +from app.core.directory_index_contract import DIRECTORY_INDEX_CONTRACT +from app.models.system_settings import DirectoryIndexPolicy +from app.services.directory_index import ( + APPLICATION_ID, + SCHEMA_VERSION, + STORAGE_FILE_SUFFIXES, + DirectoryIndex, + connect_directory_database, + validate_storage_ancestors, +) + +WRITE_HEADROOM_BYTES = DIRECTORY_INDEX_CONTRACT.limits["write_headroom_bytes"] +LEASE_CLEANUP_BATCH = DIRECTORY_INDEX_CONTRACT.limits["lease_cleanup_batch"] +LEGACY_SNAPSHOT_SUFFIXES = frozenset({".idx", ".bak"}) +LEGACY_STORAGE_SUFFIXES = LEGACY_SNAPSHOT_SUFFIXES | {".tmp"} +LEGACY_TEMP_NAME = re.compile(r"\.[0-9a-f]{8}_[a-z0-9_]{8}\.tmp") +logger = logging.getLogger(__name__) + + +class DirectoryDiskBudgetError(RuntimeError): + """Growth cannot proceed without the configured operation headroom.""" + + +@dataclass(frozen=True) +class DirectoryDiskUsage: + managed_bytes: int + reserved_bytes: int + budget_bytes: int + growth_paused: bool + + @property + def over_budget(self) -> bool: + return self.managed_bytes + self.reserved_bytes > self.budget_bytes + + +class DirectoryIndexResources: + """Reserve bounded operations without trimming active catalogue rows.""" + + def __init__(self, namespace: Path, policy: DirectoryIndexPolicy | None = None) -> None: + self.namespace = namespace.absolute() + self.policy = DirectoryIndexPolicy() if policy is None else DirectoryIndexPolicy.model_validate(policy.model_dump()) + self.lock = threading.RLock() + self.reserved_bytes = 0 + self.growth_paused = False + self.active_paths: dict[Path, int] = {} + self._next_cleanup_at = 0.0 + + def register_source(self, path: Path) -> None: + with self.lock: + absolute = path.absolute() + self.active_paths[absolute] = self.active_paths.get(absolute, 0) + 1 + + def release_source(self, path: Path) -> None: + with self.lock: + absolute = path.absolute() + owners = self.active_paths.get(absolute, 0) + if owners > 1: + self.active_paths[absolute] = owners - 1 + else: + self.active_paths.pop(absolute, None) + self._next_cleanup_at = 0.0 + + def _inactive_access(self, path: Path) -> int | None: + if path in self.active_paths or not path.is_file(): + return None + descriptor = DirectoryIndex.acquire_storage_lease(path) + try: + with closing(connect_directory_database(path, readonly=True)) as connection: + if connection.execute("PRAGMA application_id").fetchone() != (APPLICATION_ID,): + return None + if connection.execute("PRAGMA user_version").fetchone() != (SCHEMA_VERSION,): + return None + metadata = connection.execute("SELECT source_kind, last_access_at_ms FROM source_metadata WHERE id = 1").fetchone() + if metadata is None or metadata[0] != "smb" or type(metadata[1]) is not int or metadata[1] < 0: + return None + return metadata[1] + finally: + os.close(descriptor) + + def _delete_inactive(self, path: Path, expected_access: int) -> bool: + if path in self.active_paths: + return False + validate_storage_ancestors(path.parent) + directory_descriptor = ( + os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0)) if os.name == "posix" else None + ) + try: + directory_identity = os.fstat(directory_descriptor) if directory_descriptor is not None else path.parent.lstat() + descriptor = DirectoryIndex.acquire_storage_lease(path) + try: + if not path.is_file(): + return False + database_identity = path.lstat() + with closing(connect_directory_database(path)) as connection: + if connection.execute("PRAGMA application_id").fetchone() != (APPLICATION_ID,): + return False + if connection.execute("SELECT last_access_at_ms FROM source_metadata WHERE id = 1").fetchone() != (expected_access,): + return False + checkpoint = connection.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone() + if checkpoint is None or checkpoint[0] or checkpoint[1] != checkpoint[2]: + return False + validate_storage_ancestors(path.parent) + current_directory = path.parent.lstat() + current_database = ( + os.stat(path.name, dir_fd=directory_descriptor, follow_symlinks=False) + if directory_descriptor is not None + else path.lstat() + ) + if (directory_identity.st_dev, directory_identity.st_ino) != (current_directory.st_dev, current_directory.st_ino) or ( + database_identity.st_dev, + database_identity.st_ino, + ) != (current_database.st_dev, current_database.st_ino): + raise PermissionError("Directory storage changed during cleanup. Restore the private namespace and retry cleanup.") + DirectoryIndex._validate_storage_files(path) + for suffix in STORAGE_FILE_SUFFIXES: + if suffix == ".lease": + continue + candidate = Path(str(path) + suffix) + try: + if directory_descriptor is not None: + os.unlink(candidate.name, dir_fd=directory_descriptor) + else: + candidate.unlink() + except FileNotFoundError: + pass + logger.debug("Inactive directory storage removed.", extra={"event": "directory_inactive_evicted"}) + return True + finally: + os.close(descriptor) + finally: + if directory_descriptor is not None: + os.close(directory_descriptor) + + def cleanup_orphan_leases(self) -> int: + """Remove a bounded set of unowned leases with no database or sidecars.""" + with self.lock: + removed = 0 + try: + entries = os.scandir(self.namespace) + except FileNotFoundError: + return 0 + with entries: + for entry in entries: + if removed >= LEASE_CLEANUP_BATCH: + break + if not entry.name.endswith(".sqlite3.lease"): + continue + stem = entry.name.removesuffix(".sqlite3.lease") + if len(stem) != 64 or any(character not in "0123456789abcdef" for character in stem): + continue + path = self.namespace / entry.name.removesuffix(".lease") + if path in self.active_paths: + continue + try: + removed += DirectoryIndex.remove_orphan_lease(path) + except OSError: + continue + return removed + + def cleanup_legacy(self, *, now_ms: int | None = None) -> int: + """Retire bounded obsolete snapshots without reading them or touching recent writer work.""" + if os.name != "posix": + return 0 + with self.lock: + validate_storage_ancestors(self.namespace) + try: + descriptor = os.open(self.namespace.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + except FileNotFoundError: + return 0 + try: + parent = os.fstat(descriptor) + if parent.st_uid != os.getuid() or parent.st_mode & (stat.S_IWGRP | stat.S_IWOTH): + logger.warning( + "Legacy directory-cache cleanup was skipped. Use a cache root owned by the service account " + "without group/public write access to permit legacy cleanup." + ) + return 0 + now = int(time.time() * 1000) if now_ms is None else now_ms + removed = 0 + with os.scandir(descriptor) as entries: + for entry in entries: + if removed >= LEASE_CLEANUP_BATCH: + break + path = Path(entry.name) + temporary = LEGACY_TEMP_NAME.fullmatch(entry.name) is not None + if not temporary: + if path.suffix not in LEGACY_SNAPSHOT_SUFFIXES: + continue + try: + if str(UUID(path.stem)) != path.stem: + continue + except ValueError: + continue + try: + metadata = entry.stat(follow_symlinks=False) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != os.getuid() or metadata.st_nlink != 1: + continue + if temporary and now - int(metadata.st_mtime * 1000) < self.policy.inactive_retention_seconds * 1000: + continue + current = os.stat(entry.name, dir_fd=descriptor, follow_symlinks=False) + if current != metadata: + continue + os.unlink(entry.name, dir_fd=descriptor) + removed += 1 + except OSError: + continue + return removed + finally: + os.close(descriptor) + + def cleanup_inactive(self, *, now_ms: int | None = None, pressure: bool = False) -> int: + """Keep bounded recent inactive databases; never delete an owned source.""" + with self.lock: + self.usage() + if pressure: + try: + DirectoryIndex.discard_quarantine(self.namespace) + except OSError: + pass + now = int(time.time() * 1000) if now_ms is None else now_ms + retained: list[tuple[int, Path, int]] = [] + legacy_removed = self.cleanup_legacy(now_ms=now) + removed = legacy_removed + try: + entries = os.scandir(self.namespace) + except FileNotFoundError: + return removed + with entries: + for entry in entries: + if not entry.name.endswith(".sqlite3"): + continue + stem = entry.name.removesuffix(".sqlite3") + if len(stem) != 64 or any(character not in "0123456789abcdef" for character in stem): + continue + path = self.namespace / entry.name + try: + accessed = self._inactive_access(path) + if accessed is None: + continue + effective_access = accessed if accessed <= now else 0 + if now - effective_access >= self.policy.inactive_retention_seconds * 1000: + removed += self._delete_inactive(path, accessed) + continue + heapq.heappush(retained, (effective_access, path, accessed)) + if len(retained) > self.policy.max_retained_inactive_sources: + _, old_path, old_access = heapq.heappop(retained) + removed += self._delete_inactive(old_path, old_access) + except (OSError, sqlite3.Error): + continue + while pressure and retained: + usage = self.usage() + if ( + usage.managed_bytes + usage.reserved_bytes + WRITE_HEADROOM_BYTES <= usage.budget_bytes + and usage.reserved_bytes + WRITE_HEADROOM_BYTES <= self._available_disk_bytes() + ): + break + _, path, accessed = heapq.heappop(retained) + try: + removed += self._delete_inactive(path, accessed) + except (OSError, sqlite3.Error): + continue + self.cleanup_orphan_leases() + self._next_cleanup_at = ( + 0.0 + if legacy_removed == LEASE_CLEANUP_BATCH + else time.monotonic() + + min(self.policy.inactive_retention_seconds, DIRECTORY_INDEX_CONTRACT.policy["inactive_retention_seconds"].minimum) + ) + return removed + + def _directory_bytes(self, directory: Path, *, legacy_only: bool = False) -> int: + for ancestor in (directory, *directory.parents): + try: + metadata = ancestor.lstat() + except FileNotFoundError: + continue + if stat.S_ISLNK(metadata.st_mode): + raise ValueError("Directory-index storage must not use symbolic links. Select private local storage.") + try: + entries = os.scandir(directory) + except FileNotFoundError: + return 0 + total = 0 + with entries: + for entry in entries: + if legacy_only and Path(entry.name).suffix not in LEGACY_STORAGE_SUFFIXES: + continue + try: + metadata = entry.stat(follow_symlinks=False) + except FileNotFoundError: + continue + if not stat.S_ISREG(metadata.st_mode): + raise ValueError("Managed directory-index storage contains unsupported entries. Remove links and non-file entries.") + total += metadata.st_size + return total + + def usage(self) -> DirectoryDiskUsage: + with self.lock: + managed = self._directory_bytes(self.namespace) + self._directory_bytes(self.namespace.parent, legacy_only=True) + return DirectoryDiskUsage(managed, self.reserved_bytes, self.policy.disk_budget_bytes, self.growth_paused) + + def _available_disk_bytes(self) -> int: + for ancestor in (self.namespace, *self.namespace.parents): + try: + metadata = ancestor.lstat() + except FileNotFoundError: + continue + if not stat.S_ISDIR(metadata.st_mode): + raise ValueError("Directory-index storage must use a real directory without links. Select private local storage.") + return shutil.disk_usage(ancestor).free + raise ValueError("Directory-index filesystem is unavailable. Restore the cache location before retrying.") + + def apply_policy(self, policy: DirectoryIndexPolicy) -> None: + validated = DirectoryIndexPolicy.model_validate(policy.model_dump()) + with self.lock: + self.policy = validated + self._next_cleanup_at = 0.0 + usage = self.usage() + self.growth_paused = usage.managed_bytes + usage.reserved_bytes + WRITE_HEADROOM_BYTES > validated.disk_budget_bytes + + @contextmanager + def reserve_growth(self) -> Iterator[None]: + with self.lock: + usage = self.usage() + pressure = ( + usage.managed_bytes + usage.reserved_bytes + WRITE_HEADROOM_BYTES > self.policy.disk_budget_bytes + or usage.reserved_bytes + WRITE_HEADROOM_BYTES > self._available_disk_bytes() + ) + if pressure or time.monotonic() >= self._next_cleanup_at: + self.cleanup_inactive(pressure=pressure) + usage = self.usage() + if usage.managed_bytes + usage.reserved_bytes + WRITE_HEADROOM_BYTES > self.policy.disk_budget_bytes: + self.growth_paused = True + raise DirectoryDiskBudgetError( + "Directory-index disk headroom is unavailable. Increase the host disk budget or remove eligible inactive indexes, then retry." + ) + if self.reserved_bytes + WRITE_HEADROOM_BYTES > self._available_disk_bytes(): + self.growth_paused = True + raise DirectoryDiskBudgetError( + "Directory-index filesystem has insufficient free space. Free disk space or select another cache location, then retry." + ) + self.reserved_bytes += WRITE_HEADROOM_BYTES + self.growth_paused = False + try: + yield + finally: + with self.lock: + self.reserved_bytes -= WRITE_HEADROOM_BYTES diff --git a/backend/app/services/directory_monitor.py b/backend/app/services/directory_monitor.py index 7422bf9ad..0119d7184 100644 --- a/backend/app/services/directory_monitor.py +++ b/backend/app/services/directory_monitor.py @@ -4,11 +4,13 @@ """ import asyncio +import hashlib import logging import random import threading from collections.abc import Awaitable, Callable from typing import Dict, Optional +from uuid import uuid4 from smbprotocol.change_notify import ( CompletionFilter, @@ -31,6 +33,7 @@ from app.services.system_settings import get_smbclient_policy_kwargs from app.storage.smb_errors import translate_smb_target_error +from app.utils.worker_wait import wait_for_worker logger = logging.getLogger(__name__) @@ -63,6 +66,7 @@ def __init__(self) -> None: self._starting: dict[str, threading.Event] = {} self._connection_generations: dict[str, int] = {} self._running = True + self._startup_cleanups: set[asyncio.Task[None]] = set() def get_connection_generation(self, connection_id: str) -> int: """Return the lifecycle generation used to detect stale startups.""" @@ -84,7 +88,7 @@ def start_monitoring( port: int = 445, on_change_callback: Optional[Callable[[str, str], Awaitable[None]]] = None, expected_generation: int | None = None, - ) -> None: + ) -> "MonitoredDirectory": """ Start monitoring a directory for changes. @@ -107,14 +111,19 @@ def start_monitoring( while True: with self._lock: + if not self._running: + raise RuntimeError("Directory monitoring is shutting down. Reconnect after the service restarts.") if self._connection_generations.get(connection_id, 0) != requested_generation: - raise RuntimeError(f"Connection was retired before monitor {key} could start") + raise RuntimeError("Connection was retired before monitoring could start. Reconnect and subscribe again.") existing_monitor = self._monitors.get(key) + if existing_monitor is not None and existing_monitor.stopped is True: + del self._monitors[key] + existing_monitor = None if existing_monitor is not None: existing_monitor.subscriber_count += 1 - logger.debug(f"Increased subscriber count for {key} to {existing_monitor.subscriber_count}") - return + logger.debug("File-list subscriber added.") + return existing_monitor start_event = self._starting.get(key) if start_event is None: @@ -156,7 +165,7 @@ def retire_monitor() -> None: raise with self._lock: - connection_retired = self._connection_generations.get(connection_id, 0) != connection_generation + connection_retired = not self._running or self._connection_generations.get(connection_id, 0) != connection_generation stopped_during_start = monitor_stopped.is_set() if not connection_retired and not stopped_during_start: self._monitors[key] = monitor @@ -165,11 +174,12 @@ def retire_monitor() -> None: if connection_retired: monitor.stop() - raise RuntimeError(f"Connection was retired while monitor {key} was starting") + raise RuntimeError("Connection was retired during monitoring startup. Reconnect and subscribe again.") if stopped_during_start: raise RuntimeError("Directory monitoring stopped during startup") - logger.debug(f"Started monitoring: {key}") + logger.debug("File-list monitoring started.") + return monitor async def start_monitoring_async( self, @@ -185,18 +195,27 @@ async def start_monitoring_async( ) -> None: """Start a monitor without blocking the application event loop.""" - await asyncio.to_thread( - self.start_monitoring, - connection_id, - path, - host, - share_name, - username, - password, - port, - on_change_callback, - expected_generation, + future = asyncio.get_running_loop().run_in_executor( + None, + lambda: self.start_monitoring( + connection_id, path, host, share_name, username, password, port, on_change_callback, expected_generation + ), ) + try: + await wait_for_worker(future) + except asyncio.CancelledError: + + async def release_cancelled_start() -> None: + try: + acquired = await wait_for_worker(future) + await asyncio.to_thread(self._release_subscription, f"{connection_id}:{path}", acquired) + except Exception: + logger.warning("Cancelled file-list startup finished with an error.", exc_info=True) + + cleanup = asyncio.create_task(release_cancelled_start()) + self._startup_cleanups.add(cleanup) + cleanup.add_done_callback(self._startup_cleanups.discard) + raise # # stop_monitoring @@ -211,26 +230,29 @@ def stop_monitoring(self, connection_id: str, path: str) -> None: """ key = f"{connection_id}:{path}" + self._release_subscription(key) + def _release_subscription(self, key: str, expected: "MonitoredDirectory | None" = None) -> None: + """Release only the monitor acquired by an abandoned startup, if supplied.""" with self._lock: - if key not in self._monitors: - logger.debug(f"Attempted to stop monitoring non-existent key: {key}") + if key not in self._monitors or (expected is not None and self._monitors[key] is not expected): + logger.debug("File-list subscription is already stopped.") return monitor = self._monitors[key] monitor.subscriber_count -= 1 if monitor.subscriber_count <= 0: - logger.debug(f"Stopping monitoring: {key}") + logger.debug("Last file-list subscriber removed.") del self._monitors[key] else: - logger.debug(f"Decreased subscriber count for {key} to {monitor.subscriber_count}") + logger.debug("File-list subscriber removed.") return try: monitor.stop() except Exception: - logger.error("Error stopping monitor %s", key, exc_info=True) + logger.error("File-list monitor could not be stopped. Retry after its worker exits.", exc_info=True) def stop_connection(self, connection_id: str) -> None: """Stop every active monitor associated with a connection.""" @@ -245,7 +267,7 @@ def stop_connection(self, connection_id: str) -> None: try: monitor.stop() except Exception: - logger.warning("Error stopping monitor %s for retired connection", key, exc_info=True) + logger.warning("Retired file-list monitor is still stopping.", exc_info=True) async def stop_connection_async(self, connection_id: str) -> None: """Stop a connection's monitors without blocking the application event loop.""" @@ -262,7 +284,7 @@ def restart_all(self) -> None: try: monitor.restart() except Exception: - logger.warning("Error restarting monitor %s after SMB policy update", key, exc_info=True) + logger.warning("File-list monitor could not restart after the policy update.", exc_info=True) async def restart_all_async(self) -> None: """Reconnect monitors without blocking the application event loop.""" @@ -283,8 +305,8 @@ def stop_all(self) -> None: for key, monitor in monitors: try: monitor.stop() - except Exception as e: - logger.error(f"Error stopping monitor {key}: {e}", exc_info=True) + except Exception: + logger.error("File-list monitor shutdown failed. Its worker retains resources until exit.", exc_info=True) logger.info("All directory monitors stopped") @@ -330,10 +352,36 @@ def __init__( self._stop_event = threading.Event() self._lifecycle_lock = threading.Lock() self._retired = False + self._restarting = False # Retry state self._retry_count = 0 self._consecutive_failures = 0 + self._failure_reported = False + self._log_context = { + "source_id": hashlib.sha256(connection_id.encode()).hexdigest()[:16], + "generation_id": uuid4().hex, + "operation": "file_list_watch", + "stage": "watcher", + } + + @property + def _logger(self) -> logging.LoggerAdapter[logging.Logger]: + return logging.LoggerAdapter(logger, self._log_context, merge_extra=True) + + def _report_watcher_failure(self, error: Exception) -> None: + """Keep one cause until a watcher result confirms recovery.""" + if not self._failure_reported: + self._failure_reported = True + self._logger.warning( + "File-list watcher is unavailable. Background recovery remains active.", + extra={"event": "directory_watcher_unavailable", "error_type": type(error).__name__}, + exc_info=error, + ) + + @property + def stopped(self) -> bool: + return self._retired or (self._stop_event.is_set() and not self._restarting) # # start @@ -382,10 +430,16 @@ def start(self) -> None: self._monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True) self._monitor_thread.start() - except Exception: - # Clean up on error + except Exception as error: self._cleanup() - raise + translated = translate_smb_target_error(error, self.path) + if isinstance(translated, (FileNotFoundError, PermissionError, NotADirectoryError)): + raise translated from error + if self._stop_event.is_set(): + raise + self._report_watcher_failure(error) + self._monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True) + self._monitor_thread.start() # # _reconnect @@ -395,23 +449,25 @@ def _reconnect(self) -> None: self._consecutive_failures += 1 - if self._consecutive_failures > MAX_RETRY_ATTEMPTS: - logger.error(f"Max retry attempts ({MAX_RETRY_ATTEMPTS}) reached for {self.connection_id}:{self.path}, giving up") - self._stop_event.set() - return + if self._consecutive_failures == MAX_RETRY_ATTEMPTS + 1: + self._logger.warning( + "File-list watcher fast retries exhausted. Capped background retries remain active.", + extra={"event": "directory_watcher_retry_exhausted"}, + ) # Calculate delay with exponential backoff and jitter base_delay = min( - INITIAL_RETRY_DELAY * (RETRY_BACKOFF_MULTIPLIER ** (self._consecutive_failures - 1)), + INITIAL_RETRY_DELAY * (RETRY_BACKOFF_MULTIPLIER ** min(self._consecutive_failures - 1, MAX_RETRY_ATTEMPTS + 1)), MAX_RETRY_DELAY, ) jitter = base_delay * RETRY_JITTER_FACTOR * (random.random() * 2 - 1) # +/- 10% - delay = max(0, base_delay + jitter) + delay = min(MAX_RETRY_DELAY, max(0, base_delay + jitter)) - logger.info( - f"Reconnecting monitor for {self.connection_id}:{self.path} " - f"(attempt {self._consecutive_failures}/{MAX_RETRY_ATTEMPTS}, delay: {delay:.1f}s)" - ) + if self._consecutive_failures <= MAX_RETRY_ATTEMPTS: + self._logger.info( + "File-list watcher retry scheduled.", + extra={"event": "directory_watcher_retry", "retry_seconds": delay}, + ) # Wait before reconnecting if self._stop_event.wait(delay): @@ -424,8 +480,7 @@ def _reconnect(self) -> None: try: self._connection = Connection(guid=None, server_name=self.host, port=self.port, require_signing=True) self._connection.connect(timeout=int(policy["connection_timeout"])) - except Exception as e: - logger.error(f"Connection failed during reconnect: {e}", exc_info=True) + except Exception: raise # Create new session @@ -458,31 +513,20 @@ def _reconnect(self) -> None: # Create new watcher self._watcher = FileSystemWatcher(self._open) - logger.info(f"Successfully reconnected monitor for {self.connection_id}:{self.path}") - def _handle_change_result(self, result: object) -> None: """Handle a completed watcher result from smbprotocol.""" if result is None: - return + raise ConnectionError("File-list monitoring stopped. Reconnect monitoring and refresh the listing.") if not isinstance(result, list): raise TypeError(f"Unexpected watcher result type: {type(result).__name__}") + if self._failure_reported: + self._logger.info("File-list watcher recovered.", extra={"event": "directory_watcher_recovered"}) + self._failure_reported = False self._consecutive_failures = 0 - for action_info in result: - action = action_info["action"].get_value() - filename = action_info["file_name"].get_value() - action_name = self._get_action_name(action) - logger.debug( - "Change detected in %s:%s - %s: %s", - self.connection_id, - self.path, - action_name, - filename, - ) - self._notify_change() # Restart watcher for next change using a fresh watcher instance. @@ -495,8 +539,8 @@ def _notify_change(self) -> None: try: asyncio.set_event_loop(loop) loop.run_until_complete(self.on_change_callback(self.connection_id, self.path)) - except Exception as cb_error: - logger.error("Error in change callback: %s", cb_error, exc_info=True) + except Exception: + logger.error("File-list invalidation callback failed. Refresh the listing to verify changes.", exc_info=True) finally: asyncio.set_event_loop(None) loop.close() @@ -519,8 +563,14 @@ def _monitor_loop(self) -> None: ) if self._watcher is None: - logger.warning(f"Watcher is None for {self.connection_id}:{self.path}, exiting loop") - break + try: + self._cleanup() + self._reconnect() + if not self._stop_event.is_set(): + self._notify_change() + except Exception as error: + self._report_watcher_failure(error) + continue # Start the watcher (this is a blocking call until change occurs) # Wait for response (blocking) @@ -540,9 +590,6 @@ def _monitor_loop(self) -> None: except Exception as e: if not self._stop_event.is_set(): - error_type = type(e).__name__ - error_msg = str(e).lower() - # STATUS_DELETE_PENDING (0xc0000056) means the # directory we are watching was deleted. This is # normal — stop monitoring instead of retrying. @@ -552,79 +599,20 @@ def _monitor_loop(self) -> None: NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, NtStatus.STATUS_OBJECT_PATH_NOT_FOUND, ) or isinstance(e, FileNotFoundError): - logger.info( - "Watched directory deleted, stopping monitor: %s:%s", - self.connection_id, - self.path, - ) + logger.info("Watched directory was deleted. File-list monitoring stopped.") self._notify_change() break - logger.error( - "Error waiting for changes in %s:%s: %s: %s", - self.connection_id, - self.path, - error_type, - e, - exc_info=True, - ) - - # Check if the error is due to connection/timeout issues - is_connection_error = ( - "socket" in error_msg - or "connection" in error_msg - or "closed" in error_msg - or "timeout" in error_msg - or "timed out" in error_msg - or error_type in ("TimeoutError", "ConnectionError", "OSError") - ) - - if is_connection_error: - logger.warning( - "Connection/timeout issue detected for %s:%s, attempting recovery (consecutive failures: %s)", - self.connection_id, - self.path, - self._consecutive_failures, - ) - # Clean up old resources - self._cleanup() - # Try to reconnect with exponential backoff - try: - self._reconnect() - # Success - reset failure counter - self._consecutive_failures = 0 - logger.info("Successfully reconnected %s:%s", self.connection_id, self.path) - except Exception as reconnect_error: - logger.error( - "Failed to reconnect (attempt %s/%s): %s", - self._consecutive_failures, - MAX_RETRY_ATTEMPTS, - reconnect_error, - exc_info=True, - ) - # _reconnect handles retry logic and stop event - if self._stop_event.is_set(): - break - else: - # Other errors - wait briefly and retry with same connection - logger.info("Non-connection error, recreating watcher after 5s delay") - self._stop_event.wait(5) - if not self._stop_event.is_set() and self._open: - self._watcher = FileSystemWatcher(self._open) - - except Exception as e: - logger.error( - "Monitor loop error for %s:%s: %s", - self.connection_id, - self.path, - e, - exc_info=True, - ) + self._report_watcher_failure(e) + self._cleanup() + + except Exception: + logger.error("File-list monitor stopped unexpectedly. Reopen the directory to subscribe again.", exc_info=True) finally: self._cleanup() - if self.on_stopped and not self._stop_event.is_set(): + if self.on_stopped and not self._restarting: self.on_stopped() - logger.debug("Monitor loop stopped for %s:%s", self.connection_id, self.path) + logger.debug("File-list monitor worker stopped.") # # _get_action_name @@ -657,16 +645,28 @@ def restart(self) -> None: with self._lifecycle_lock: if self._retired: return - self._stop_locked() - self._stop_event.clear() - self._retry_count = 0 - self._consecutive_failures = 0 - self.start() + self._restarting = True + try: + try: + stopped = self._stop_locked() + if not stopped: + return + self._stop_event.clear() + finally: + self._restarting = False + self._retry_count = 0 + self._consecutive_failures = 0 + self.start() + except Exception: + self._stop_event.set() + if self.on_stopped: + self.on_stopped() + raise - def _stop_locked(self) -> None: + def _stop_locked(self) -> bool: """Stop monitor resources while holding the lifecycle lock.""" - logger.debug(f"Stopping monitor for {self.connection_id}:{self.path}") + logger.debug("File-list monitor stop requested.") self._stop_event.set() # Cancel any pending watcher @@ -674,16 +674,18 @@ def _stop_locked(self) -> None: try: self._watcher.cancel() except Exception as e: - logger.warning(f"Error canceling watcher: {e}") + logger.warning("File-list watcher cancellation failed.", extra={"error_type": type(e).__name__}) # Wait for monitor thread to finish if self._monitor_thread and self._monitor_thread.is_alive(): self._monitor_thread.join(timeout=5.0) if self._monitor_thread.is_alive(): - logger.warning(f"Monitor thread did not stop cleanly for {self.connection_id}:{self.path}") + logger.warning("File-list watcher is still stopping. Its worker retains resources until exit.") + return False # Clean up SMB resources self._cleanup() + return True # # _cleanup @@ -691,45 +693,46 @@ def _stop_locked(self) -> None: def _cleanup(self) -> None: """Clean up SMB resources in proper order.""" + self._watcher = None # Close in reverse order of creation to prevent leaks if self._open: try: self._open.close() - logger.debug(f"Closed directory handle for {self.connection_id}:{self.path}") + logger.debug("File-list directory handle closed.") except Exception as e: - logger.warning(f"Error closing directory handle: {e}") + logger.warning("File-list directory handle cleanup failed.", extra={"error_type": type(e).__name__}) finally: self._open = None if self._tree: try: self._tree.disconnect() - logger.debug(f"Disconnected tree for {self.connection_id}:{self.path}") + logger.debug("File-list tree disconnected.") except Exception as e: - logger.warning(f"Error disconnecting tree: {e}") + logger.warning("File-list tree cleanup failed.", extra={"error_type": type(e).__name__}) finally: self._tree = None if self._session: try: self._session.disconnect() - logger.debug(f"Disconnected session for {self.connection_id}:{self.path}") + logger.debug("File-list session disconnected.") except Exception as e: - logger.warning(f"Error disconnecting session: {e}") + logger.warning("File-list session cleanup failed.", extra={"error_type": type(e).__name__}) finally: self._session = None if self._connection: try: self._connection.disconnect() - logger.debug(f"Disconnected connection for {self.connection_id}:{self.path}") + logger.debug("File-list connection disconnected.") except RuntimeError as e: # smbprotocol bug: disconnect() tries to join the current # thread when called from the message-worker after a timeout. - logger.debug(f"Ignored RuntimeError during connection disconnect for {self.connection_id}:{self.path}: {e}") + logger.debug("File-list disconnect worker could not join itself.", extra={"error_type": type(e).__name__}) except Exception as e: - logger.warning(f"Error disconnecting connection: {e}") + logger.warning("File-list connection cleanup failed.", extra={"error_type": type(e).__name__}) finally: self._connection = None diff --git a/backend/app/services/directory_policy_grants.py b/backend/app/services/directory_policy_grants.py new file mode 100644 index 000000000..cb7a5cdec --- /dev/null +++ b/backend/app/services/directory_policy_grants.py @@ -0,0 +1,176 @@ +"""Separate-purpose, single-use policy grants with current administrator checks.""" + +from __future__ import annotations + +import hashlib +import uuid +from datetime import datetime, timedelta, timezone + +from fastapi import HTTPException, status +from joserfc.errors import JoseError +from sqlalchemy import delete, func, update +from sqlmodel import Session, col, select + +from app.core.authorization import Capability, user_has_capability +from app.core.config import settings +from app.core.directory_index_contract import ( + DIRECTORY_POLICY_GRANT_SECONDS, + DIRECTORY_POLICY_TOKEN_CLAIM, + DIRECTORY_POLICY_TOKEN_CLASS, + DIRECTORY_POLICY_TOKEN_PURPOSE, + MAX_DIRECTORY_POLICY_GRANTS, +) +from app.core.security import create_access_token, decode_access_token, is_user_expired +from app.core.system_setting_definitions import DirectoryIndexPolicyScope +from app.models.companion_uri_token_jti import CompanionDirectoryPolicyGrant +from app.models.system_settings import ( + DirectoryPolicyGrantBinding, + DirectoryPolicyGrantRead, + DirectoryPolicyGrantReceipt, + DirectoryPolicyGrantRedeem, +) +from app.models.user import User +from app.services.system_settings import build_directory_index_settings_read + + +def backend_policy_identity() -> str: + if not settings.secret_key: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Policy signing is unavailable. Check backend secret initialization." + ) + return hashlib.sha256((DIRECTORY_POLICY_TOKEN_PURPOSE + ":" + settings.secret_key).encode("utf-8")).hexdigest() + + +def _administrator(session: Session, user_id: uuid.UUID) -> User: + user = session.get(User, user_id, populate_existing=True) + if user is None or not user.is_active or is_user_expired(user) or not user_has_capability(user, Capability.ACCESS_ADMIN_SETTINGS): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Policy approval is no longer authorized. Sign in as a current administrator and request a new grant.", + ) + return user + + +def issue_directory_policy_grant(binding: DirectoryPolicyGrantBinding, administrator: User, session: Session) -> DirectoryPolicyGrantRead: + user = _administrator(session, administrator.id) + now = datetime.now(timezone.utc) + session.exec(delete(CompanionDirectoryPolicyGrant).where(col(CompanionDirectoryPolicyGrant.expires_at) <= now)) + count = session.exec(select(func.count()).select_from(CompanionDirectoryPolicyGrant)).one() + if count >= MAX_DIRECTORY_POLICY_GRANTS: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Policy approval capacity is temporarily full. Wait one minute and request a new grant.", + ) + policy = build_directory_index_settings_read(DirectoryIndexPolicyScope.COMPANION, session) + identity = backend_policy_identity() + jti = uuid.uuid4() + expires = now + timedelta(seconds=DIRECTORY_POLICY_GRANT_SECONDS) + token = create_access_token( + data={ + "sub": str(user.id), + "jti": str(jti), + "token_class": DIRECTORY_POLICY_TOKEN_CLASS, + "purpose": DIRECTORY_POLICY_TOKEN_PURPOSE, + DIRECTORY_POLICY_TOKEN_CLAIM: True, + "scope": DirectoryIndexPolicyScope.COMPANION.value, + "companion_id": str(binding.companion_id), + "challenge": binding.challenge, + "origin": binding.origin, + "backend_identity": identity, + "revision": policy.revision, + "token_version": user.token_version, + }, + expires_delta=timedelta(seconds=DIRECTORY_POLICY_GRANT_SECONDS), + ) + session.add( + CompanionDirectoryPolicyGrant( + jti=jti, + user_id=user.id, + companion_id=binding.companion_id, + challenge=binding.challenge, + origin=binding.origin, + backend_identity=identity, + revision=policy.revision, + token_version=user.token_version, + expires_at=expires, + ) + ) + session.commit() + return DirectoryPolicyGrantRead( + grant=token, expires_in_seconds=DIRECTORY_POLICY_GRANT_SECONDS, revision=policy.revision, backend_identity=identity + ) + + +def redeem_directory_policy_grant(binding: DirectoryPolicyGrantRedeem, session: Session) -> DirectoryPolicyGrantReceipt: + try: + claims = decode_access_token(binding.grant) + if claims is None: + raise ValueError("Missing policy claims") + jti = uuid.UUID(str(claims.get("jti"))) + except (JoseError, ValueError, TypeError) as error: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Policy grant is invalid or expired. Request a new approval grant." + ) from error + identity = backend_policy_identity() + if ( + claims.get("token_class") != DIRECTORY_POLICY_TOKEN_CLASS + or claims.get("purpose") != DIRECTORY_POLICY_TOKEN_PURPOSE + or claims.get(DIRECTORY_POLICY_TOKEN_CLAIM) is not True + or claims.get("scope") != DirectoryIndexPolicyScope.COMPANION.value + or claims.get("companion_id") != str(binding.companion_id) + or claims.get("challenge") != binding.challenge + or claims.get("origin") != binding.origin + or claims.get("backend_identity") != identity + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Policy grant does not match this companion, challenge or origin. Request a new approval grant.", + ) + record = session.get(CompanionDirectoryPolicyGrant, jti, populate_existing=True) + now = datetime.now(timezone.utc) + if record is None or record.consumed_at is not None or record.expires_at.replace(tzinfo=timezone.utc) <= now: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Policy grant was used or expired. Request a new approval grant.") + if ( + record.companion_id != binding.companion_id + or record.challenge != binding.challenge + or record.origin != binding.origin + or record.backend_identity != identity + or claims.get("sub") != str(record.user_id) + or type(claims.get("revision")) is not int + or claims.get("revision") != record.revision + or type(claims.get("token_version")) is not int + or claims.get("token_version") != record.token_version + ): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Policy grant binding is invalid. Request a new approval grant.") + user = _administrator(session, record.user_id) + if user.token_version != record.token_version: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Administrator credentials changed. Request a new approval grant." + ) + current = build_directory_index_settings_read(DirectoryIndexPolicyScope.COMPANION, session) + if current.revision != record.revision: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Companion policy changed. Request a new approval grant for the current revision." + ) + consumed = session.execute( + update(CompanionDirectoryPolicyGrant) + .where( + col(CompanionDirectoryPolicyGrant.jti) == jti, + col(CompanionDirectoryPolicyGrant.consumed_at).is_(None), + col(CompanionDirectoryPolicyGrant.expires_at) > now, + ) + .values(consumed_at=now) + .returning(col(CompanionDirectoryPolicyGrant.jti)) + ).first() + if consumed is None: + session.rollback() + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Policy grant was used or expired. Request a new approval grant.") + session.commit() + return DirectoryPolicyGrantReceipt( + companion_id=binding.companion_id, + challenge=binding.challenge, + origin=binding.origin, + backend_identity=identity, + revision=current.revision, + policy=current.policy, + ) diff --git a/backend/app/services/directory_traversal.py b/backend/app/services/directory_traversal.py new file mode 100644 index 000000000..453ce3c54 --- /dev/null +++ b/backend/app/services/directory_traversal.py @@ -0,0 +1,405 @@ +"""Handle-bound SMB directory reads without automatic DFS redirection.""" + +import logging +from collections.abc import Callable, Generator, Iterator +from dataclasses import dataclass +from datetime import datetime +from pathlib import PureWindowsPath +from threading import Lock +from typing import Any +from uuid import UUID + +from smbclient._io import SMBFileTransaction, query_info +from smbclient._pool import get_smb_tree +from smbprotocol.exceptions import NoMoreFiles, NoSuchFile, SMBException, SMBResponseException +from smbprotocol.file_info import FileAllInformation, FileFsVolumeInformation, FileInformationClass +from smbprotocol.header import NtStatus +from smbprotocol.open import ( + CreateDisposition, + CreateOptions, + DirectoryAccessMask, + FileAttributes, + ImpersonationLevel, + Open, + QueryDirectoryFlags, + ShareAccess, +) + +from app.core.directory_index_contract import DIRECTORY_INDEX_CONTRACT +from app.services.directory_index import validate_directory_path + +DIRECTORY_READ_BUFFER_BYTES = 64 * 1024 +MAX_SOURCE_DEPTH = DIRECTORY_INDEX_CONTRACT.limits["max_source_depth"] +MAX_SOURCE_PATH_BYTES = DIRECTORY_INDEX_CONTRACT.limits["max_source_path_bytes"] +SOURCE_BINDING_ERROR = "Directory source binding changed or is unsupported. Reconnect the source and retry verification." +TRAVERSAL_LIMIT_ERROR = ( + "Directory path exceeds the supported traversal bounds. Shorten the path or reduce nesting, then reconnect the source." +) +UNSAFE_COMPONENT_CHARACTERS = frozenset('\\/:*?"<>|') +SMB_UNAVAILABLE_FILE_IDS = frozenset((0, 0xFFFFFFFFFFFFFFFF)) +OPTIONAL_INFORMATION_UNAVAILABLE = frozenset( + (NtStatus.STATUS_INVALID_INFO_CLASS, NtStatus.STATUS_NOT_SUPPORTED, NtStatus.STATUS_INVALID_PARAMETER) +) +logger = logging.getLogger(__name__) + + +class DirectoryTraversalError(OSError): + """The directory source cannot establish safe traversal.""" + + +class DirectoryBindingChangedError(DirectoryTraversalError): + """The pinned source root cannot be reused.""" + + +def _validate_traversal_path(path: str) -> None: + if ( + len(path) > MAX_SOURCE_PATH_BYTES + or len(path.encode("utf-8")) > MAX_SOURCE_PATH_BYTES + or (bool(path) and path.count("/") >= MAX_SOURCE_DEPTH) + ): + raise DirectoryTraversalError(TRAVERSAL_LIMIT_ERROR) + + +def _component(name: str) -> None: + validate_directory_path(name) + if any(character in UNSAFE_COMPONENT_CHARACTERS or ord(character) < 32 for character in name) or name.endswith((".", " ")): + raise DirectoryTraversalError("Directory name cannot be read safely through SMB. Rename the entry and retry verification.") + + +@dataclass(frozen=True) +class SmbDirectoryIdentity: + server_guid: UUID + session_id: int + tree_id: int + volume_serial: int | None + volume_created: datetime | None + file_id: int + native_name: str + + def same_binding(self, other: "SmbDirectoryIdentity") -> bool: + return ( + self.session_id, + self.tree_id, + self.native_name, + ) == ( + other.session_id, + other.tree_id, + other.native_name, + ) and (self.file_id in SMB_UNAVAILABLE_FILE_IDS or other.file_id in SMB_UNAVAILABLE_FILE_IDS or self.file_id == other.file_id) + + +@dataclass(frozen=True) +class SmbDirectoryEntryInformation: + file_attributes: int + + +@dataclass(frozen=True) +class SmbDirectoryEntry: + name: str | None + smb_info: SmbDirectoryEntryInformation + + +class SmbDirectoryHandle: + """Own one no-follow handle; disconnected handles cannot reopen themselves.""" + + def __init__(self, path: str, check: Callable[[], None], *, capabilities: dict[str, bool] | None = None, **session: Any) -> None: + check() + self._capabilities = capabilities if capabilities is not None else {} + requested = PureWindowsPath(path) + tree, native_path = get_smb_tree(path, **session) + expected_native = "\\".join(requested.parts[1:]) + if ( + not requested.is_absolute() + or not requested.drive.startswith("\\\\") + or tree.is_dfs_share is not False + or PureWindowsPath(tree.share_name) != PureWindowsPath(requested.drive) + or native_path != expected_native + ): + raise DirectoryTraversalError(SOURCE_BINDING_ERROR) + self.fd = Open(tree, native_path) + try: + self.fd.create( + ImpersonationLevel.Impersonation, + DirectoryAccessMask.FILE_LIST_DIRECTORY | DirectoryAccessMask.FILE_READ_ATTRIBUTES, + FileAttributes.FILE_ATTRIBUTE_DIRECTORY, + ShareAccess.FILE_SHARE_READ | ShareAccess.FILE_SHARE_WRITE, + CreateDisposition.FILE_OPEN, + CreateOptions.FILE_DIRECTORY_FILE | CreateOptions.FILE_OPEN_REPARSE_POINT, + ) + check() + if self.fd.file_attributes & FileAttributes.FILE_ATTRIBUTE_REPARSE_POINT: + raise DirectoryTraversalError(SOURCE_BINDING_ERROR) + except BaseException: + try: + if not self.closed: + self.close() + except Exception as error: + logger.warning("Directory handle cleanup failed (%s).", type(error).__name__) + raise + + @property + def closed(self) -> bool: + return not self.fd.connected + + @property + def name(self) -> str: + return "Directory index source" + + def open(self, **kwargs: Any) -> None: + raise DirectoryTraversalError(SOURCE_BINDING_ERROR) + + def close(self) -> None: + self.fd.close() + + def identity(self, check: Callable[[], None]) -> SmbDirectoryIdentity: + check() + with SMBFileTransaction(self) as transaction: + query_info(transaction, FileAllInformation, output_buffer_length=DIRECTORY_READ_BUFFER_BYTES) + check() + (information,) = transaction.results + volume_serial = None + volume_created = None + if self._capabilities.get("volume_information", True): + try: + with SMBFileTransaction(self) as volume_transaction: + query_info(volume_transaction, FileFsVolumeInformation, output_buffer_length=DIRECTORY_READ_BUFFER_BYTES) + (volume,) = volume_transaction.results + volume_serial = volume["volume_serial_number"].get_value() + volume_created = volume["volume_creation_time"].get_value() + except SMBResponseException as error: + if error.status not in OPTIONAL_INFORMATION_UNAVAILABLE: + raise + self._capabilities["volume_information"] = False + check() + internal = information["internal_information"].get_value() + name = information["name_information"].get_value() + standard = information["standard_information"].get_value() + basic = information["basic_information"].get_value() + tree = self.fd.tree_connect + server_guid = self.fd.connection.server_guid + native_name = name["file_name"].get_value() + numbers = ( + tree.session.session_id, + tree.tree_connect_id, + internal["index_number"].get_value(), + ) + if ( + self.closed + or tree.is_dfs_share is not False + or not standard["directory"].get_value() + or standard["delete_pending"].get_value() + or basic["file_attributes"].get_value() & FileAttributes.FILE_ATTRIBUTE_REPARSE_POINT + or not isinstance(server_guid, UUID) + or any(type(value) is not int for value in numbers) + or not isinstance(native_name, str) + or not native_name.startswith("\\") + or native_name.startswith("\\\\") + ): + raise DirectoryTraversalError(SOURCE_BINDING_ERROR) + _validate_traversal_path(native_name.replace("\\", "/").lstrip("/")) + native_name.encode("utf-16-le", errors="strict") + for component in native_name.split("\\")[1:]: + if component: + _component(component) + elif native_name != "\\": + raise DirectoryTraversalError(SOURCE_BINDING_ERROR) + return SmbDirectoryIdentity(server_guid, numbers[0], numbers[1], volume_serial, volume_created, numbers[2], native_name) + + def records(self, check: Callable[[], None], *, pattern: str = "*") -> Generator[Any, None, None]: + flags = QueryDirectoryFlags.SMB2_RESTART_SCANS | QueryDirectoryFlags.SMB2_REOPEN + while True: + check() + if self.closed: + raise DirectoryTraversalError(SOURCE_BINDING_ERROR) + try: + records = self.fd.query_directory( + pattern, + FileInformationClass.FILE_ID_FULL_DIRECTORY_INFORMATION, + flags=flags, + max_output=DIRECTORY_READ_BUFFER_BYTES, + ) + except (NoMoreFiles, NoSuchFile): + check() + return + check() + if not records: + raise DirectoryTraversalError("Directory enumeration made no progress. Reconnect the source and retry verification.") + flags = 0 + for record in records: + check() + yield record + + +class SmbDirectoryRead(Iterator[SmbDirectoryEntry]): + """Keep the complete parent chain held until the scan and pruning exit.""" + + def __init__( + self, + handles: list[SmbDirectoryHandle], + identities: list[SmbDirectoryIdentity], + check: Callable[[], None], + *, + retained: int = 0, + ) -> None: + self._handles = handles + self._identities = identities + self._check = check + self._retained = retained + self._records = handles[-1].records(check) + + def __next__(self) -> SmbDirectoryEntry: + while True: + record = next(self._records) + attributes = record["file_attributes"].get_value() + if type(attributes) is not int or attributes < 0: + raise DirectoryTraversalError(SOURCE_BINDING_ERROR) + try: + name = record["file_name"].get_value().decode("utf-16-le", errors="strict") + if name in (".", ".."): + continue + _component(name) + if attributes & FileAttributes.FILE_ATTRIBUTE_DIRECTORY: + parent = self._identities[-1].native_name.replace("\\", "/").lstrip("/") + _validate_traversal_path(f"{parent}/{name}" if parent else name) + except (UnicodeError, ValueError, DirectoryTraversalError): + if attributes & FileAttributes.FILE_ATTRIBUTE_DIRECTORY: + return SmbDirectoryEntry(None, SmbDirectoryEntryInformation(attributes)) + continue + return SmbDirectoryEntry(name, SmbDirectoryEntryInformation(attributes)) + + def verify(self) -> None: + self._check() + if not self._handles or not self._handles[-1].identity(self._check).same_binding(self._identities[-1]): + raise DirectoryTraversalError(SOURCE_BINDING_ERROR) + + def has_directory_child(self, name: str) -> bool: + _component(name) + parent = self._identities[-1].native_name.replace("\\", "/").lstrip("/") + _validate_traversal_path(f"{parent}/{name}" if parent else name) + native_name = name.encode("utf-16-le", errors="strict") + self.verify() + records = self._handles[-1].records(self._check, pattern=name) + try: + for record in records: + if record["file_name"].get_value() != native_name: + continue + attributes = record["file_attributes"].get_value() + if attributes & FileAttributes.FILE_ATTRIBUTE_REPARSE_POINT: + raise DirectoryTraversalError(SOURCE_BINDING_ERROR) + self.verify() + return bool(attributes & FileAttributes.FILE_ATTRIBUTE_DIRECTORY) + finally: + records.close() + self.verify() + return False + + def close(self) -> None: + self._records.close() + handles, self._handles = self._handles, [] + _close_handles(handles[self._retained :]) + + +def _close_handles(handles: list[SmbDirectoryHandle]) -> None: + failure: Exception | None = None + for handle in reversed(handles): + try: + handle.close() + except Exception as error: + if failure is None: + failure = error + else: + logger.warning("Directory handle cleanup failed (%s).", type(error).__name__) + if failure is not None: + raise failure + + +class SmbDirectoryRoot: + """Own no-delete root handles until source workers and shutdown finish.""" + + def __init__(self, path: str) -> None: + self._path = PureWindowsPath(path) + if not self._path.is_absolute() or not self._path.drive.startswith("\\\\") or self._path.drive.startswith(("\\\\?", "\\\\.")): + raise DirectoryTraversalError(SOURCE_BINDING_ERROR) + self._prefix = self._path.parts[1:] + _validate_traversal_path("/".join(self._prefix)) + for component in self._prefix: + _component(component) + self._binding: tuple[SmbDirectoryIdentity, ...] | None = None + self._capabilities: dict[str, bool] = {} + self._held_handles: list[SmbDirectoryHandle] = [] + self._closed = False + self._lock = Lock() + + def close(self) -> None: + with self._lock: + self._closed = True + handles, self._held_handles = self._held_handles, [] + _close_handles(handles) + + def replacement(self) -> "SmbDirectoryRoot": + """Bind fresh handles after a connection or root-binding change.""" + replacement = SmbDirectoryRoot(str(self._path)) + self.close() + return replacement + + def read(self, parent: str, check: Callable[[], None], **session: Any) -> SmbDirectoryRead: + _validate_traversal_path(parent) + _validate_traversal_path("/".join((*self._prefix, parent)) if parent else "/".join(self._prefix)) + if parent: + validate_directory_path(parent) + components = (*self._prefix, *(parent.split("/") if parent else ())) + handles: list[SmbDirectoryHandle] = [] + identities: list[SmbDirectoryIdentity] = [] + try: + with self._lock: + check() + if self._closed: + raise DirectoryBindingChangedError(SOURCE_BINDING_ERROR) + for held, expected in zip(self._held_handles, self._binding or (), strict=True): + try: + if not held.identity(check).same_binding(expected): + raise DirectoryBindingChangedError(SOURCE_BINDING_ERROR) + except (OSError, SMBException, ValueError) as error: + if isinstance(error, InterruptedError): + raise + raise DirectoryBindingChangedError(SOURCE_BINDING_ERROR) from error + path = PureWindowsPath(self._path.anchor) + for position in range(len(components) + 1): + check() + if position: + component = components[position - 1] + _component(component) + path /= component + try: + handle = SmbDirectoryHandle(str(path), check, capabilities=self._capabilities, **session) + handles.append(handle) + identity = handle.identity(check) + except (OSError, SMBException, ValueError) as error: + if position <= len(self._prefix) and not isinstance(error, InterruptedError): + raise DirectoryBindingChangedError(SOURCE_BINDING_ERROR) from error + raise + if identities and ( + identity.session_id != identities[-1].session_id + or identity.tree_id != identities[-1].tree_id + or identity.native_name != identities[-1].native_name.rstrip("\\") + "\\" + components[position - 1] + ): + raise DirectoryTraversalError(SOURCE_BINDING_ERROR) + identities.append(identity) + binding = tuple(identities[: len(self._prefix) + 1]) + with self._lock: + check() + if self._closed: + raise DirectoryBindingChangedError(SOURCE_BINDING_ERROR) + if self._binding is None: + self._binding = binding + self._held_handles = handles[: len(binding)] + return SmbDirectoryRead(handles, identities, check, retained=len(binding)) + elif not all(current.same_binding(expected) for current, expected in zip(binding, self._binding, strict=True)): + raise DirectoryBindingChangedError(SOURCE_BINDING_ERROR) + return SmbDirectoryRead(handles, identities, check) + except BaseException: + try: + _close_handles(handles) + except Exception as error: + logger.warning("Directory handle cleanup failed (%s).", type(error).__name__) + raise diff --git a/backend/app/services/history_common.py b/backend/app/services/history_common.py index d0d5e9039..1b50cb1eb 100644 --- a/backend/app/services/history_common.py +++ b/backend/app/services/history_common.py @@ -1,6 +1,5 @@ from __future__ import annotations -import unicodedata import uuid from datetime import datetime, timezone from pathlib import PurePosixPath @@ -9,11 +8,14 @@ from app.models.user import User from app.services.connection_access import get_accessible_connection_or_404 +from app.services.directory_index import normalize_search_text, single_term_rank LOCAL_DRIVE_PREFIX = "local-drive:" HISTORY_SCORE_HALF_LIFE_SECONDS = 30 * 24 * 60 * 60 HISTORY_REPEAT_INTERVAL_SECONDS = 60 HISTORY_SUBSTRING_MATCH_RANK = 3 +HISTORY_QUERY_BATCH_SIZE = 256 +SEARCH_CONTRACT_VERSION = 1 def history_usage_score(score: float, last_used: datetime, now: datetime) -> float: @@ -29,8 +31,16 @@ def updated_history_usage_score(score: float, last_used: datetime, now: datetime def normalize_history_search_text(value: str) -> str: - decomposed = unicodedata.normalize("NFKD", value) - return "".join(character for character in decomposed if not unicodedata.combining(character)).casefold() + return normalize_search_text(value) + + +def history_search_relevance(field_key: str, basename_key: str, terms: tuple[str, ...]) -> tuple[int, int] | None: + if not terms: + return 0, HISTORY_SUBSTRING_MATCH_RANK + matched = tuple(term for term in terms if term in field_key) + if not matched: + return None + return len(matched), min(single_term_rank(basename_key, term) for term in matched) def history_match_rank(normalized_name: str, normalized_query: str) -> int | None: diff --git a/backend/app/services/recent_directories.py b/backend/app/services/recent_directories.py index e479de362..2d522f1c1 100644 --- a/backend/app/services/recent_directories.py +++ b/backend/app/services/recent_directories.py @@ -1,16 +1,20 @@ from __future__ import annotations +import heapq import uuid from datetime import datetime, timezone +from typing import Iterator from fastapi import HTTPException, status from sqlmodel import Session, col, select from app.models.recent_directory import RecentDirectory, RecentDirectoryRead from app.models.user import User +from app.services.directory_index import search_terms from app.services.history_common import ( + HISTORY_QUERY_BATCH_SIZE, LOCAL_DRIVE_PREFIX, - directory_match_rank, + history_search_relevance, history_usage_score, normalize_history_search_text, normalize_recent_history_path, @@ -87,32 +91,47 @@ def _is_accessible_history_record(*, record: RecentDirectory, current_user: User def search_recent_directories(*, query: str, limit: int, current_user: User, session: Session) -> list[RecentDirectoryRead]: bounded_limit = max(1, min(limit, MAX_RECENT_DIRECTORY_RESULTS, get_file_search_settings(session).result_limit)) - normalized_query = normalize_history_search_text(query.strip()) + terms = search_terms(query) records = session.exec( - select(RecentDirectory).where(RecentDirectory.user_id == current_user.id).order_by(col(RecentDirectory.last_visited_at).desc()) - ).all() + select(RecentDirectory).where(RecentDirectory.user_id == current_user.id).execution_options(yield_per=HISTORY_QUERY_BATCH_SIZE) + ) now = datetime.now(timezone.utc) - matched: list[tuple[int, RecentDirectory]] = [] - for record in records: - if normalized_query not in normalize_history_search_text(record.path) or not _is_accessible_history_record( - record=record, current_user=current_user, session=session - ): - continue - matched.append((directory_match_rank(record.path, normalized_query), record)) - matched.sort( + + def matches() -> Iterator[tuple[int, int, RecentDirectory]]: + try: + for record in records: + relevance = history_search_relevance( + normalize_history_search_text(record.path), normalize_history_search_text(record.path.rsplit("/", 1)[-1]), terms + ) + if relevance is not None and _is_accessible_history_record(record=record, current_user=current_user, session=session): + yield *relevance, record + finally: + records.close() + + matched = heapq.nsmallest( + bounded_limit, + matches(), key=lambda result: ( - result[0], - -history_usage_score(result[1].usage_score, result[1].last_visited_at, now), - -result[1].last_visited_at.timestamp(), - str(result[1].id), - ) + -result[0], + result[1], + -history_usage_score(result[2].usage_score, result[2].last_visited_at, now), + -result[2].last_visited_at.replace(tzinfo=timezone.utc).timestamp(), + normalize_history_search_text(result[2].path), + result[2].path, + result[2].connection_id, + str(result[2].id), + ), ) return [ RecentDirectoryRead.model_validate(record).model_copy( - update={"match_rank": rank, "usage_score": history_usage_score(record.usage_score, record.last_visited_at, now)} + update={ + "matched_term_count": count, + "match_rank": rank, + "usage_score": history_usage_score(record.usage_score, record.last_visited_at, now), + } ) - for rank, record in matched[:bounded_limit] + for count, rank, record in matched ] diff --git a/backend/app/services/recent_files.py b/backend/app/services/recent_files.py index 39c0d1c41..aede741ad 100644 --- a/backend/app/services/recent_files.py +++ b/backend/app/services/recent_files.py @@ -1,16 +1,20 @@ from __future__ import annotations +import heapq import uuid from datetime import datetime, timezone from pathlib import PurePosixPath +from typing import Iterator from fastapi import HTTPException, status from sqlmodel import Session, col, select from app.models.recent_file import RecentFile, RecentFileRead from app.models.user import User +from app.services.directory_index import search_terms from app.services.history_common import ( - history_match_rank, + HISTORY_QUERY_BATCH_SIZE, + history_search_relevance, history_usage_score, normalize_history_search_text, normalize_recent_history_path, @@ -158,29 +162,50 @@ def record_recent_file( def search_recent_files(*, query: str, limit: int, current_user: User, session: Session) -> list[RecentFileRead]: bounded_limit = get_recent_file_result_limit(limit=limit, session=session) - normalized_query = _normalize_for_matching(query.strip()) + terms = search_terms(query) records = session.exec( - select(RecentFile).where(RecentFile.user_id == current_user.id).order_by(col(RecentFile.last_opened_at).desc()) - ).all() - matched: list[tuple[int, RecentFile]] = [] - for record in records: - rank = history_match_rank(_normalize_for_matching(record.file_name), normalized_query) - if rank is not None: - matched.append((rank, record)) + select(RecentFile).where(RecentFile.user_id == current_user.id).execution_options(yield_per=HISTORY_QUERY_BATCH_SIZE) + ) now = datetime.now(timezone.utc) - matched.sort( + + def matches() -> Iterator[tuple[int, int, RecentFile]]: + try: + for record in records: + key = _normalize_for_matching(record.file_name) + relevance = history_search_relevance(key, key, terms) + if relevance is None: + continue + try: + validate_history_connection_access(connection_id=record.connection_id, current_user=current_user, session=session) + except (HTTPException, ValueError): + continue + yield *relevance, record + finally: + records.close() + + matched = heapq.nsmallest( + bounded_limit, + matches(), key=lambda result: ( - result[0], - -history_usage_score(result[1].usage_score, result[1].last_opened_at, now), - -result[1].last_opened_at.timestamp(), - str(result[1].id), - ) + -result[0], + result[1], + -history_usage_score(result[2].usage_score, result[2].last_opened_at, now), + -result[2].last_opened_at.replace(tzinfo=timezone.utc).timestamp(), + _normalize_for_matching(result[2].file_name), + result[2].path, + result[2].connection_id, + str(result[2].id), + ), ) return [ RecentFileRead.model_validate(record).model_copy( - update={"match_rank": rank, "usage_score": history_usage_score(record.usage_score, record.last_opened_at, now)} + update={ + "matched_term_count": count, + "match_rank": rank, + "usage_score": history_usage_score(record.usage_score, record.last_opened_at, now), + } ) - for rank, record in matched[:bounded_limit] + for count, rank, record in matched ] diff --git a/backend/app/services/system_settings.py b/backend/app/services/system_settings.py index 37ecc8ac2..5933c7d3e 100644 --- a/backend/app/services/system_settings.py +++ b/backend/app/services/system_settings.py @@ -20,9 +20,13 @@ import app.core.config as config_module import app.db.database as database_module from app import __version__ +from app.core.directory_index_contract import MAX_WIRE_INTEGER from app.core.logging import get_logger from app.core.system_setting_definitions import ( + DIRECTORY_INDEX_REVISION_KEYS, + DIRECTORY_INDEX_SETTING_KEYS, SYSTEM_SETTING_DEFINITIONS, + DirectoryIndexPolicyScope, IntegerSystemSettingDefinition, SystemSettingKey, SystemSettingSource, @@ -33,6 +37,9 @@ AboutSettingsRead, AdvancedSystemSettingsRead, AdvancedSystemSettingUpdate, + DirectoryIndexPolicy, + DirectoryIndexSettingsRead, + DirectoryIndexSettingUpdate, FileSearchSettings, FileSearchSettingsRead, FileSearchSettingsUpdate, @@ -282,6 +289,70 @@ def get_integer_setting_value(key: SystemSettingKey) -> int: return _resolve_integer_setting(SYSTEM_SETTING_DEFINITIONS[key]).value +def build_directory_index_settings_read(scope: DirectoryIndexPolicyScope, session: Session) -> DirectoryIndexSettingsRead: + from app.services.directory_policy_grants import backend_policy_identity + + fields: dict[str, IntegerSystemSettingRead] = {} + values: dict[str, int] = {} + try: + for field, key in DIRECTORY_INDEX_SETTING_KEYS[scope].items(): + definition = SYSTEM_SETTING_DEFINITIONS[key] + override = session.get(SystemSetting, key.value) + value = definition.default_value if override is None else _parse_override_value(definition, override.value) + values[field] = value + fields[field] = IntegerSystemSettingRead( + key=key, + label=definition.label, + description=definition.description, + value=value, + source=SystemSettingSource.DEFAULT if override is None else SystemSettingSource.DATABASE, + default_value=definition.default_value, + min_value=definition.min_value, + max_value=definition.max_value, + step=definition.step, + ) + revision_row = session.get(SystemSetting, DIRECTORY_INDEX_REVISION_KEYS[scope].value) + revision = 0 if revision_row is None else int(revision_row.value) + if not 0 <= revision <= MAX_WIRE_INTEGER: + raise ValueError("Directory-index policy revision is outside the supported range.") + return DirectoryIndexSettingsRead( + scope=scope, + policy=DirectoryIndexPolicy.model_validate(values), + backend_identity=backend_policy_identity(), + revision=revision, + fields=fields, + ) + except ValueError as error: + raise ValueError("Stored directory-index policy is invalid. Restore valid administration values before indexing.") from error + + +def get_directory_index_policy(scope: DirectoryIndexPolicyScope = DirectoryIndexPolicyScope.BACKEND) -> DirectoryIndexPolicy: + with Session(database_module.engine) as session: + return build_directory_index_settings_read(scope, session).policy + + +def update_directory_index_settings( + scope: DirectoryIndexPolicyScope, payload: DirectoryIndexSettingUpdate, *, updated_by_user_id: uuid.UUID, session: Session +) -> DirectoryIndexSettingUpdate: + _begin_immediate_transaction(session) + current = build_directory_index_settings_read(scope, session) + values = current.policy.model_dump() + values[payload.field] = payload.value + policy = DirectoryIndexPolicy.model_validate(values) + if getattr(current.policy, payload.field) == payload.value: + session.commit() + return payload + if current.revision >= MAX_WIRE_INTEGER: + raise ValueError("Directory-index policy revision is exhausted. Restore policy revision storage before changing it.") + _write_system_setting( + session, DIRECTORY_INDEX_SETTING_KEYS[scope][payload.field], str(getattr(policy, payload.field)), updated_by_user_id + ) + _write_system_setting(session, DIRECTORY_INDEX_REVISION_KEYS[scope], str(current.revision + 1), updated_by_user_id) + session.commit() + store.refresh_from_session(session) + return payload + + def _decode_string_set(raw_value: str | None, *, key: SystemSettingKey) -> set[str]: if raw_value is None: return set() diff --git a/backend/app/storage/smb.py b/backend/app/storage/smb.py index b62ec5702..5b5ea2489 100644 --- a/backend/app/storage/smb.py +++ b/backend/app/storage/smb.py @@ -4,7 +4,7 @@ import uuid from collections.abc import AsyncIterator, Callable from datetime import datetime, timezone -from pathlib import PurePosixPath +from pathlib import PurePosixPath, PureWindowsPath from time import monotonic from typing import Any, Awaitable, BinaryIO, TypeVar, cast @@ -29,9 +29,10 @@ from app.services.content_transfer import SourceDeleteError, SourceDeletionOutcomeUnknown from app.services.system_settings import get_integer_setting_value, get_smbclient_policy_kwargs from app.storage.base import ExclusiveWriter, MoveSourceReader, ProgressCallback, RandomAccessReader, StorageBackend -from app.storage.smb_errors import translate_smb_target_error +from app.storage.smb_errors import SMBConnectionRetired, SMBMutationUncertain, SMBOperationTimeout, translate_smb_target_error from app.storage.smb_pool import get_connection_pool, get_smb_connection_cache from app.utils.file_type_registry import get_mime_type +from app.utils.worker_wait import wait_for_worker logger = logging.getLogger(__name__) @@ -54,8 +55,14 @@ def _retain_move_cleanup(task: asyncio.Task[None]) -> None: def completed(result: asyncio.Task[None]) -> None: _MOVE_CLEANUP_TASKS.discard(result) - if not result.cancelled() and result.exception() is not None: - logger.warning("Deferred SMB move cleanup failed", exc_info=result.exception()) + if not result.cancelled(): + error = result.exception() + if error is not None: + logger.warning( + "Deferred SMB cleanup failed. Reconnect before retrying.", + extra={"event": "smb_cleanup_failed", "error_type": type(error).__name__, "errno": getattr(error, "errno", None)}, + exc_info=error, + ) task.add_done_callback(completed) @@ -70,6 +77,45 @@ def __init__(self, *, backend: "SMBBackend", smb_path: str, handle: BinaryIO, co self._connection_lease = connection_lease self._lock = asyncio.Lock() self._closed = False + self._inflight: asyncio.Future[Any] | None = None + self._cleanup: asyncio.Task[None] | None = None + + async def _operate( + self, operation: Callable[[], BlockingResultT], timeout: float, *, mutation_paths: tuple[str, ...] = () + ) -> BlockingResultT: + if self._closed: + raise ValueError("SMB reader is closed") + if self._inflight is not None and not self._inflight.done(): + raise RuntimeError("Previous SMB reader operation is still running") + future = asyncio.get_running_loop().run_in_executor(None, lambda: self._backend._run_observed_operation(operation, mutation_paths)) + self._inflight = future + try: + result = await wait_for_worker(future, timeout) + except TimeoutError as error: + self._closed = True + try: + await self._backend._invalidate_pooled_connection("reader_timeout", abort=True) + except Exception: + logger.warning("SMB reader generation retirement failed. Reconnect before retrying.", exc_info=True) + if mutation_paths: + raise SMBMutationUncertain() from error + raise SMBOperationTimeout("SMB reader timed out. Reconnect before retrying; active work remains owned.") from error + except asyncio.CancelledError: + self._closed = True + raise + except Exception as error: + pool = await get_connection_pool() + if mutation_paths and pool.generation_aborting(self._backend._connection_cache): + raise SMBMutationUncertain() from error + raise + else: + pool = await get_connection_pool() + if pool.generation_aborting(self._backend._connection_cache): + self._closed = True + if mutation_paths: + raise SMBMutationUncertain() + raise SMBConnectionRetired("SMB reader connection changed. Reopen the source before retrying.") + return result async def read_at(self, offset: int, length: int) -> bytes: if offset < 0 or length < 0: @@ -83,27 +129,28 @@ def _read_at() -> bytes: self._handle.seek(offset) return self._handle.read(length) - return await self._backend._run_blocking_smb_call( - "read_file_at", - _read_at, - SMB_READ_CHUNK_TIMEOUT_SECONDS, - smb_path=self._smb_path, - ) + return await self._operate(_read_at, SMB_READ_CHUNK_TIMEOUT_SECONDS) async def close(self) -> None: async with self._lock: - if self._closed: - return - self._closed = True - try: - await self._backend._run_blocking_smb_call( - "close_file_after_random_read", - self._handle.close, - SMB_FILE_CLOSE_TIMEOUT_SECONDS, - smb_path=self._smb_path, - ) - finally: - await self._connection_lease.__aexit__(None, None, None) # type: ignore[attr-defined] + if self._cleanup is None: + self._closed = True + + async def cleanup() -> None: + try: + if self._inflight is not None: + try: + await wait_for_worker(self._inflight) + except Exception: + pass + await asyncio.get_running_loop().run_in_executor(None, self._handle.close) + finally: + await self._connection_lease.__aexit__(None, None, None) # type: ignore[attr-defined] + + self._cleanup = asyncio.create_task(cleanup()) + _retain_move_cleanup(self._cleanup) + cleanup_task = self._cleanup + await wait_for_worker(cleanup_task, SMB_FILE_CLOSE_TIMEOUT_SECONDS) class _SMBMoveSourceReader(_SMBRandomAccessReader): @@ -114,15 +161,12 @@ def __init__(self, *, backend: "SMBBackend", smb_path: str, handle: BinaryIO, co self._source_path = source_path self._inflight: asyncio.Future[Any] | None = None self._cleanup: asyncio.Task[None] | None = None + self._delete_dispatched = False - async def _operate(self, operation: Callable[[], BlockingResultT], timeout: float) -> BlockingResultT: - if self._closed: - raise ValueError("SMB move source reader is closed") - if self._inflight is not None and not self._inflight.done(): - raise RuntimeError("Previous SMB move source operation is still running") - future = asyncio.get_running_loop().run_in_executor(None, operation) - self._inflight = future - return await asyncio.wait_for(asyncio.shield(future), timeout) + async def _operate( + self, operation: Callable[[], BlockingResultT], timeout: float, *, mutation_paths: tuple[str, ...] = () + ) -> BlockingResultT: + return await super()._operate(lambda: self._backend._run_observed_operation(operation, mutation_paths), timeout) async def read_at(self, offset: int, length: int) -> bytes: if offset < 0 or length < 0: @@ -168,17 +212,22 @@ async def cleanup() -> None: try: if self._inflight is not None: try: - await asyncio.shield(self._inflight) - except BaseException: + await wait_for_worker(self._inflight) + except Exception: pass - await asyncio.get_running_loop().run_in_executor(None, self._handle.close) + await asyncio.get_running_loop().run_in_executor( + None, + lambda: self._backend._run_observed_operation( + self._handle.close, (self._smb_path,) if self._delete_dispatched else () + ), + ) finally: await self._connection_lease.__aexit__(None, None, None) # type: ignore[attr-defined] self._cleanup = asyncio.create_task(cleanup()) _retain_move_cleanup(self._cleanup) cleanup_task = self._cleanup - await asyncio.wait_for(asyncio.shield(cleanup_task), SMB_FILE_CLOSE_TIMEOUT_SECONDS) + await wait_for_worker(cleanup_task, SMB_FILE_CLOSE_TIMEOUT_SECONDS) async def commit_delete(self, *, cancellation: Callable[[], bool] | None = None) -> None: async with self._lock: @@ -217,13 +266,14 @@ def _delete_opened_file() -> None: destination_mutated=True, ) dispatched = True + self._delete_dispatched = True pending = connection.send(request, session_id, tree_connect_id) response = connection.receive(pending) parsed = SMB2SetInfoResponse() parsed.unpack(response["data"].get_value()) try: - await self._operate(_delete_opened_file, SMB_DELETE_TIMEOUT_SECONDS) + await self._operate(_delete_opened_file, SMB_DELETE_TIMEOUT_SECONDS, mutation_paths=(self._smb_path,)) except SourceDeleteError: raise except SMBResponseException as error: @@ -279,6 +329,7 @@ async def close(self) -> None: self._handle.close, SMB_FILE_CLOSE_TIMEOUT_SECONDS, smb_path=self._smb_path, + mutation_paths=(self._smb_path,), ) finally: await self._connection_lease.__aexit__(None, None, None) # type: ignore[attr-defined] @@ -293,6 +344,7 @@ async def abort_and_delete_if_owned(self) -> bool: lambda: smbclient.remove(self._smb_path, **self._backend._smb_auth_kwargs()), SMB_DELETE_TIMEOUT_SECONDS, smb_path=self._smb_path, + mutation_paths=(self._smb_path,), ) except OSError: return False @@ -301,6 +353,7 @@ async def abort_and_delete_if_owned(self) -> bool: self._handle.close, SMB_FILE_CLOSE_TIMEOUT_SECONDS, smb_path=self._smb_path, + mutation_paths=(self._smb_path,), ) self._closed = True await self._connection_lease.__aexit__(None, None, None) # type: ignore[attr-defined] @@ -353,6 +406,7 @@ def __init__( # Persisted connections share one isolated cache across request-scoped # backend instances. Detail-only test backends remain self-contained. self._connection_cache = get_smb_connection_cache(connection_context_key) if connection_context_key is not None else {} + self._connection_context_key = connection_context_key self._close_connection_cache_on_disconnect = close_connection_cache_on_disconnect # Normalize path_prefix: strip slashes, collapse to empty string for root @@ -385,7 +439,7 @@ async def connect(self) -> None: """ # Connection pooling is handled transparently in operations. - logger.debug(f"SMB backend ready (will use pooled connection): //{self.host}:{self.port}/{self.share_name}") + logger.debug("SMB backend is ready for pooled work.") # # disconnect @@ -411,7 +465,7 @@ async def disconnect(self) -> None: ) return - logger.debug(f"SMB backend released (connection remains in pool): //{self.host}/{self.share_name}") + logger.debug("SMB backend released; pooled connections remain owned.") # # _normalize_prefix @@ -472,7 +526,7 @@ def _build_smb_path(self, path: str) -> str: return f"{self._base_path}\\{full_rel.replace('/', '\\')}" return self._base_path - async def _invalidate_pooled_connection(self, reason: str) -> None: + async def _invalidate_pooled_connection(self, reason: str, *, abort: bool = False) -> None: pool = await get_connection_pool() await pool.invalidate_connection( host=self.host, @@ -481,6 +535,7 @@ async def _invalidate_pooled_connection(self, reason: str) -> None: share_name=self.share_name, connection_cache=self._connection_cache, reason=reason, + abort=abort, ) async def _run_blocking_smb_call( @@ -490,6 +545,7 @@ async def _run_blocking_smb_call( timeout_seconds: float, *, smb_path: str, + mutation_paths: tuple[str, ...] = (), ) -> BlockingResultT: try: return await self._run_blocking_smb_operation( @@ -497,15 +553,55 @@ async def _run_blocking_smb_call( operation, timeout_seconds, smb_path=smb_path, + mutation_paths=mutation_paths, ) except asyncio.TimeoutError as exc: - raise TimeoutError(f"SMB operation timed out during {operation_name}") from exc + raise SMBOperationTimeout(f"SMB operation timed out during {operation_name}. Check the connection before retrying.") from exc except Exception as error: translated = translate_smb_target_error(error, smb_path) if translated is not error: raise translated from error raise + def _observe_mutation_paths(self, paths: tuple[str, ...]) -> None: + """Keep disposable index hints separate from the source operation result.""" + if not paths or self._connection_context_key is None: + return + from app.services.directory_cache import SQLiteConnectionDirectoryCache, get_directory_cache_manager + + cache = None + try: + cache = get_directory_cache_manager().get_cache(self._connection_context_key) + if not isinstance(cache, SQLiteConnectionDirectoryCache): + return + if ( + cache._connection_cache is not self._connection_cache + or (cache.host, cache.share_name, cache.port, cache.username, cache.password, cache._path_prefix) + != (self.host, self.share_name, self.port, self.username, self.password, self._path_prefix) + or cache._stop_event.is_set() + ): + return + root = PureWindowsPath(self._build_smb_path("")) + parents = set() + for path in paths: + relative = PureWindowsPath(path).relative_to(root) + normalized = self._normalize_relative_path(relative.as_posix()) if relative.parts else "" + parents.add(normalized.rpartition("/")[0]) + for parent in sorted(parents): + cache.observe_parent(parent) + except Exception as error: + if isinstance(cache, SQLiteConnectionDirectoryCache): + cache._observation_lost() + logger.warning("Directory mutation hints could not be queued (%s).", type(error).__name__) + + def _run_observed_operation(self, operation: Callable[[], BlockingResultT], mutation_paths: tuple[str, ...]) -> BlockingResultT: + """Fence source reads before dispatch and reconcile only after actual exit.""" + self._observe_mutation_paths(mutation_paths) + try: + return operation() + finally: + self._observe_mutation_paths(mutation_paths) + async def _run_blocking_smb_operation( self, operation_name: str, @@ -513,41 +609,61 @@ async def _run_blocking_smb_operation( timeout_seconds: float, *, smb_path: str, + mutation_paths: tuple[str, ...] = (), ) -> BlockingResultT: """Run executor work without releasing its SMB cache before it completes.""" loop = asyncio.get_running_loop() - operation_future = loop.run_in_executor(None, operation) + pool = await get_connection_pool() + diagnostics = pool.connection_diagnostics(self._connection_cache) + operation_future = loop.run_in_executor(None, lambda: self._run_observed_operation(operation, mutation_paths)) try: - return await self._wait_for_blocking_smb_operation( + result = await self._wait_for_blocking_smb_operation( operation_future, operation_name=operation_name, smb_path=smb_path, timeout_seconds=timeout_seconds, + diagnostics=diagnostics, ) except asyncio.TimeoutError: pool = await get_connection_pool() await pool.retain_connection_until_future_complete(self._connection_cache, operation_future) - logger.error( - "Timeout during SMB %s for '%s' after %.1fs", - operation_name, - smb_path, - timeout_seconds, + logger.warning( + "SMB operation timed out. Active work remains owned until its worker exits.", + extra={ + **pool.connection_diagnostics(self._connection_cache), + "event": "smb_operation_timeout", + "operation": operation_name.replace(" ", "_"), + "stage": "operation", + "duration_ms": timeout_seconds * 1000, + }, ) try: - await self._invalidate_pooled_connection(f"timeout during {operation_name}") + await self._invalidate_pooled_connection(f"timeout during {operation_name}", abort=True) except Exception: logger.warning( "Failed to invalidate pooled SMB connection after timeout during %s", operation_name, exc_info=True, ) + if mutation_paths: + raise SMBMutationUncertain() from None raise except asyncio.CancelledError: pool = await get_connection_pool() await pool.retain_connection_until_future_complete(self._connection_cache, operation_future) raise + except Exception as error: + if mutation_paths and pool.generation_aborting(self._connection_cache): + raise SMBMutationUncertain() from error + raise + else: + if pool.generation_aborting(self._connection_cache): + if mutation_paths: + raise SMBMutationUncertain() + raise SMBConnectionRetired("SMB connection changed during the operation. Refresh the source and retry the request.") + return result async def _wait_for_blocking_smb_operation( self, @@ -556,15 +672,17 @@ async def _wait_for_blocking_smb_operation( operation_name: str, smb_path: str, timeout_seconds: float, + diagnostics: dict[str, str | int] | None = None, ) -> BlockingResultT: try: - return await asyncio.wait_for(asyncio.shield(operation_future), timeout=timeout_seconds) + return await wait_for_worker(operation_future, timeout_seconds) except (asyncio.TimeoutError, asyncio.CancelledError): operation_future.add_done_callback( lambda completed_operation: self._report_late_smb_operation_result( completed_operation, operation_name=operation_name, smb_path=smb_path, + diagnostics=diagnostics, ) ) raise @@ -575,15 +693,19 @@ def _report_late_smb_operation_result( *, operation_name: str, smb_path: str, + diagnostics: dict[str, str | int] | None = None, ) -> None: + fields = {**(diagnostics or {}), "operation": operation_name.replace(" ", "_"), "stage": "late_completion"} try: operation_future.result() except asyncio.CancelledError: - logger.warning("SMB %s for '%s' was cancelled after timing out", operation_name, smb_path) + logger.warning("SMB operation was cancelled after caller exit.", extra={**fields, "event": "smb_operation_late_cancel"}) except Exception: - logger.warning("SMB %s for '%s' failed after timing out", operation_name, smb_path, exc_info=True) + logger.warning( + "SMB operation failed after caller exit.", extra={**fields, "event": "smb_operation_late_failure"}, exc_info=True + ) else: - logger.warning("SMB %s for '%s' completed after timing out", operation_name, smb_path) + logger.warning("SMB operation completed after caller exit.", extra={**fields, "event": "smb_operation_late_success"}) # # list_directory @@ -592,7 +714,7 @@ async def list_directory(self, path: str = "") -> DirectoryListing: """List contents of a directory""" smb_path = self._build_smb_path(path) - logger.debug(f"Listing directory: path='{path}' -> smb_path='{smb_path}'") + logger.debug("SMB directory listing started.", extra={"operation": "list_directory"}) try: # Acquire connection from pool @@ -638,7 +760,7 @@ def _scan_directory() -> list[FileInfo]: ) result.append(file_info) except Exception as e: - logger.warning(f"Failed to process {entry.name}: {e}") + logger.warning("SMB directory entry metadata is unavailable.", extra={"error_type": type(e).__name__}) # Add basic entry even if processing fails result.append( FileInfo( @@ -663,11 +785,8 @@ def _scan_directory() -> list[FileInfo]: return DirectoryListing(path=path or "/", items=items, total=len(items)) - except TimeoutError: - logger.error( - f"Timeout listing directory '{path}' (smb_path='{smb_path}') after {SMB_LIST_DIRECTORY_TIMEOUT_SECONDS:.0f} seconds" - ) - raise TimeoutError(f"SMB operation timed out while listing directory: {path}") + except TimeoutError as error: + raise SMBOperationTimeout("SMB operation timed out while listing a directory. Check the connection before retrying.") from error except (FileNotFoundError, PermissionError, NotADirectoryError): raise @@ -749,9 +868,6 @@ async def read_file(self, path: str) -> AsyncIterator[bytes]: # Larger chunks also use more SMB credits chunk_size = get_integer_setting_value(SystemSettingKey.SMB_READ_CHUNK_SIZE_BYTES) - # Build full SMB path - smb_path = self._build_smb_path(path) - try: # Acquire connection from pool pool = await get_connection_pool() @@ -772,19 +888,14 @@ async def read_file(self, path: str) -> AsyncIterator[bytes]: for attempt in range(max_retries): try: - file_handle = await self._run_blocking_smb_call( - "open_file_for_read", - lambda: smbclient.open_file( - smb_path, - mode="rb", - share_access="rwd", - **self._smb_auth_kwargs(), + file_handle = cast( + _SMBRandomAccessReader, + await self._open_random_access_reader( + path, share_access="rwd", operation_name="open_file_for_read", buffering=None ), - SMB_FILE_OPEN_TIMEOUT_SECONDS, - smb_path=smb_path, ) if attempt > 0: - logger.info(f"Successfully opened file after {attempt + 1} attempts: {path}") + logger.info("SMB read open recovered.", extra={"operation": "read_file", "event": "smb_read_recovered"}) break # Success except Exception as e: # Check for retryable errors @@ -794,8 +905,7 @@ async def read_file(self, path: str) -> AsyncIterator[bytes]: if (is_lock_error or is_credit_error) and attempt < max_retries - 1: # Wait with exponential backoff and retry - error_type = "credits exhausted" if is_credit_error else "file locked" - logger.warning(f"SMB {error_type}, retrying in {retry_delay}s (attempt {attempt + 1}/{max_retries}): {path}") + logger.warning("SMB read open will retry.", extra={"operation": "read_file", "retry_seconds": retry_delay}) await asyncio.sleep(retry_delay) retry_delay *= 2 # Double the delay each time else: @@ -808,12 +918,7 @@ async def read_file(self, path: str) -> AsyncIterator[bytes]: try: while True: try: - chunk = await self._run_blocking_smb_call( - "read_file_chunk", - lambda: file_handle.read(chunk_size), - SMB_READ_CHUNK_TIMEOUT_SECONDS, - smb_path=smb_path, - ) + chunk = await file_handle._operate(lambda: file_handle._handle.read(chunk_size), SMB_READ_CHUNK_TIMEOUT_SECONDS) if not chunk: break yield chunk @@ -824,35 +929,36 @@ async def read_file(self, path: str) -> AsyncIterator[bytes]: # NtStatus 0xc0000043 = SHARING_VIOLATION (file locked during read) # Credit exhaustion = "Request requires X credits but only Y credits are available" if "0xc0000034" in error_str or "does not exist" in error_str.lower(): - logger.warning(f"File was deleted during read: {path}") + logger.warning("SMB source was deleted during read.", extra={"operation": "read_file"}) raise FileNotFoundError(f"File was deleted: {path}") elif "0xc0000043" in error_str or "sharing violation" in error_str.lower(): - logger.warning(f"File sharing violation during read (possibly being modified): {path}") + logger.warning("SMB read failed because of a sharing violation.", extra={"operation": "read_file"}) raise IOError(f"File access conflict: {path}") elif "credits" in error_str.lower() and "available" in error_str.lower(): - logger.error(f"SMB credit exhaustion during read: {path} - {read_error}") + logger.error("SMB server credit capacity is unavailable.", extra={"operation": "read_file"}) raise IOError(f"SMB server out of credits (too many concurrent requests): {path}") else: # Unknown error during read - logger.error(f"Error reading chunk from {path}: {read_error}") + logger.error( + "SMB chunk read failed.", extra={"operation": "read_file", "error_type": type(read_error).__name__} + ) raise finally: try: - await self._run_blocking_smb_call( - "close_file_after_read", - file_handle.close, - SMB_FILE_CLOSE_TIMEOUT_SECONDS, - smb_path=smb_path, - ) + await file_handle.close() except Exception as close_error: # Log but don't raise - we're already in cleanup - logger.warning(f"Error closing file handle for {path}: {close_error}") + logger.warning( + "SMB read cleanup failed.", extra={"operation": "close_reader", "error_type": type(close_error).__name__} + ) except Exception as e: - logger.error(f"Failed to read file {path}: {e}") + logger.error("SMB read failed.", extra={"operation": "read_file", "error_type": type(e).__name__}) raise - async def _open_random_access_reader(self, path: str, *, share_access: str, operation_name: str) -> RandomAccessReader: + async def _open_random_access_reader( + self, path: str, *, share_access: str, operation_name: str, buffering: int | None = 0 + ) -> RandomAccessReader: """Open one pooled SMB handle for serialized bounded offset reads.""" smb_path = self._build_smb_path(path) @@ -867,21 +973,30 @@ async def _open_random_access_reader(self, path: str, *, share_access: str, oper ) await connection_lease.__aenter__() + open_future = asyncio.get_running_loop().run_in_executor( + None, + lambda: smbclient.open_file( + smb_path, + mode="rb", + share_access=share_access, + **({"buffering": buffering} if buffering is not None else {}), + **self._smb_auth_kwargs(), + ), + ) try: - handle = await self._run_blocking_smb_call( - operation_name, - lambda: smbclient.open_file( - smb_path, - mode="rb", - buffering=0, - share_access=share_access, - **self._smb_auth_kwargs(), - ), - SMB_FILE_OPEN_TIMEOUT_SECONDS, - smb_path=smb_path, - ) - except BaseException: - await connection_lease.__aexit__(None, None, None) + handle = await wait_for_worker(open_future, SMB_FILE_OPEN_TIMEOUT_SECONDS) + except BaseException as error: + + async def dispose_late_open() -> None: + try: + late_handle = await wait_for_worker(open_future) + await asyncio.get_running_loop().run_in_executor(None, late_handle.close) + finally: + await connection_lease.__aexit__(None, None, None) + + _retain_move_cleanup(asyncio.create_task(dispose_late_open())) + if isinstance(error, TimeoutError): + await self._invalidate_pooled_connection("reader_open_timeout", abort=True) raise return _SMBRandomAccessReader( @@ -931,19 +1046,19 @@ async def open_move_source_reader(self, path: str) -> MoveSourceReader: ), ) try: - handle = await asyncio.wait_for(asyncio.shield(open_future), SMB_FILE_OPEN_TIMEOUT_SECONDS) - except BaseException: + handle = await wait_for_worker(open_future, SMB_FILE_OPEN_TIMEOUT_SECONDS) + except BaseException as error: async def dispose_late_open() -> None: try: - late_handle = await asyncio.shield(open_future) + late_handle = await wait_for_worker(open_future) await asyncio.get_running_loop().run_in_executor(None, late_handle.close) - except Exception: - logger.warning("Failed to dispose late SMB move source open: path=%s", path, exc_info=True) finally: await connection_lease.__aexit__(None, None, None) _retain_move_cleanup(asyncio.create_task(dispose_late_open())) + if isinstance(error, TimeoutError): + await self._invalidate_pooled_connection("move_source_open_timeout", abort=True) raise reader = _SMBMoveSourceReader( backend=self, @@ -964,7 +1079,10 @@ async def open_archive_source_reader(self, path: str) -> RandomAccessReader: operation_name="open_file_for_archive_source_read", ) except Exception as exc: - logger.warning("Unable to open pinned archive source '%s': %s", path, exc) + logger.warning( + "Pinned SMB archive source could not be opened.", + extra={"operation": "open_archive_source", "error_type": type(exc).__name__}, + ) raise ArchiveSourceUnavailableError("Archive source cannot be opened with required sharing") from exc async def open_exclusive_writer(self, path: str) -> ExclusiveWriter: @@ -993,6 +1111,7 @@ async def open_exclusive_writer(self, path: str) -> ExclusiveWriter: ), SMB_FILE_OPEN_TIMEOUT_SECONDS, smb_path=smb_path, + mutation_paths=(smb_path,), ) except BaseException: await connection_lease.__aexit__(None, None, None) @@ -1028,7 +1147,7 @@ async def write_file(self, path: str, data: BinaryIO) -> int: # Write chunk size — 4 MB matches read_file for consistency write_chunk_size: int = 4 * 1024 * 1024 smb_path = self._build_smb_path(path) - logger.debug(f"Writing file: path='{path}' -> smb_path='{smb_path}'") + logger.debug("SMB write started.", extra={"operation": "write_file"}) try: pool = await get_connection_pool() @@ -1063,25 +1182,26 @@ def _write() -> int: _write, SMB_WRITE_FILE_TIMEOUT_SECONDS, smb_path=smb_path, + mutation_paths=(smb_path,), ) - logger.debug(f"Successfully wrote {bytes_written} bytes: path='{path}'") + logger.debug("SMB write completed.", extra={"operation": "write_file"}) return bytes_written except TimeoutError: - logger.error(f"Timeout writing '{path}' after {SMB_WRITE_FILE_TIMEOUT_SECONDS:.0f} seconds") + logger.error("SMB write timed out.", extra={"operation": "write_file"}) raise TimeoutError(f"SMB operation timed out while writing: {path}") except (FileNotFoundError, PermissionError, IsADirectoryError, NotADirectoryError): raise except OSError as e: error_str = str(e) if "0xc0000043" in error_str or "being used by another process" in error_str: - logger.warning(f"File locked during write: path='{path}'") + logger.warning("SMB write target is locked.", extra={"operation": "write_file"}) raise IOError(f"File is locked and cannot be written: {path}") from e - logger.error(f"Failed to write '{path}': {type(e).__name__}: {e}", exc_info=True) + logger.error("SMB write failed.", extra={"operation": "write_file", "error_type": type(e).__name__}, exc_info=True) raise except Exception as e: - logger.error(f"Failed to write '{path}': {type(e).__name__}: {e}", exc_info=True) + logger.error("SMB write failed.", extra={"operation": "write_file", "error_type": type(e).__name__}, exc_info=True) raise # @@ -1150,6 +1270,7 @@ def _remove(target: str) -> bool: lambda: _remove(smb_path), SMB_DELETE_TIMEOUT_SECONDS, smb_path=smb_path, + mutation_paths=(smb_path,), ) async def _remove_regular_file_if_exists(self, smb_path: str) -> bool: @@ -1169,6 +1290,7 @@ def _remove(target: str) -> bool: lambda: _remove(smb_path), SMB_DELETE_TIMEOUT_SECONDS, smb_path=smb_path, + mutation_paths=(smb_path,), ) # @@ -1214,7 +1336,9 @@ async def set_file_times(self, path: str, modified: datetime) -> None: ) except Exception as e: - logger.warning(f"Failed to set modification time on '{path}': {type(e).__name__}: {e}") + logger.warning( + "SMB modification time could not be set.", extra={"operation": "set_modified_time", "error_type": type(e).__name__} + ) # # delete_item @@ -1234,7 +1358,7 @@ async def delete_item(self, path: str) -> None: """ smb_path = self._build_smb_path(path) - logger.debug(f"Deleting item: path='{path}' -> smb_path='{smb_path}'") + logger.debug("SMB delete started.", extra={"operation": "delete_item"}) try: pool = await get_connection_pool() @@ -1265,7 +1389,7 @@ def _delete_recursive(target: str) -> None: # before we call rmdir. children = [entry.path for entry in smbclient.scandir(target, **self._smb_auth_kwargs())] for child_path in children: - _delete_recursive(child_path) + self._run_observed_operation(lambda: _delete_recursive(child_path), (child_path,)) smbclient.rmdir(target, **self._smb_auth_kwargs()) else: smbclient.remove(target, **self._smb_auth_kwargs()) @@ -1275,12 +1399,13 @@ def _delete_recursive(target: str) -> None: lambda: _delete_recursive(smb_path), SMB_DELETE_TIMEOUT_SECONDS, smb_path=smb_path, + mutation_paths=(smb_path,), ) - logger.debug(f"Successfully deleted: path='{path}'") + logger.debug("SMB delete completed.", extra={"operation": "delete_item"}) except TimeoutError as error: - logger.error(f"Timeout deleting '{path}' after {SMB_DELETE_TIMEOUT_SECONDS:.0f} seconds") + logger.error("SMB delete timed out.", extra={"operation": "delete_item"}) raise TimeoutError(f"SMB operation timed out while deleting: {path}") from error except OSError as e: error_str = str(e) @@ -1290,20 +1415,22 @@ def _delete_recursive(target: str) -> None: # 0xc0000056 = STATUS_DELETE_PENDING — item is already being # deleted by the server. Treat as success. if "0xc0000056" in error_str: - logger.info(f"Item already being deleted (STATUS_DELETE_PENDING): path='{path}'") + logger.debug("SMB delete is already pending.", extra={"operation": "delete_item"}) return logger.error( - f"Failed to delete '{path}': {type(e).__name__}: {e}", + "SMB delete failed.", + extra={"operation": "delete_item", "error_type": type(e).__name__}, exc_info=True, ) raise except Exception as e: error_str = str(e) if "0xc0000056" in error_str or "DeletePending" in type(e).__name__: - logger.info(f"Item already being deleted (STATUS_DELETE_PENDING): path='{path}'") + logger.debug("SMB delete is already pending.", extra={"operation": "delete_item"}) return logger.error( - f"Failed to delete '{path}': {type(e).__name__}: {e}", + "SMB delete failed.", + extra={"operation": "delete_item", "error_type": type(e).__name__}, exc_info=True, ) raise @@ -1327,6 +1454,7 @@ async def remove_empty_directory(self, path: str) -> None: lambda: smbclient.rmdir(smb_path, **self._smb_auth_kwargs()), SMB_DELETE_TIMEOUT_SECONDS, smb_path=smb_path, + mutation_paths=(smb_path,), ) except OSError as error: error_text = str(error) @@ -1338,10 +1466,11 @@ async def remove_empty_directory(self, path: str) -> None: # smbprotocol may raise non-OSError exceptions that still # carry the NTSTATUS code in their message. if "0xc0000056" in error_str or "DeletePending" in type(e).__name__: - logger.info(f"Item already being deleted (STATUS_DELETE_PENDING): path='{path}'") + logger.debug("SMB delete is already pending.", extra={"operation": "remove_empty_directory"}) return logger.error( - f"Failed to delete '{path}': {type(e).__name__}: {e}", + "SMB empty-directory removal failed.", + extra={"operation": "remove_empty_directory", "error_type": type(e).__name__}, exc_info=True, ) raise @@ -1375,7 +1504,7 @@ async def rename_item(self, path: str, new_name: str) -> None: parent = smb_src.rsplit("\\", 1)[0] smb_dst = f"{parent}\\{normalized_name}" - logger.debug(f"Renaming item: path='{path}' -> new_name='{new_name}' (smb: '{smb_src}' -> '{smb_dst}')") + logger.debug("SMB rename started.", extra={"operation": "rename_item"}) try: pool = await get_connection_pool() @@ -1393,12 +1522,13 @@ async def rename_item(self, path: str, new_name: str) -> None: lambda: smbclient.rename(smb_src, smb_dst, **self._smb_auth_kwargs()), 30.0, smb_path=smb_src, + mutation_paths=(smb_src, smb_dst), ) - logger.debug(f"Successfully renamed: '{path}' -> '{new_name}'") + logger.debug("SMB rename completed.", extra={"operation": "rename_item"}) except asyncio.TimeoutError: - logger.error(f"Timeout renaming '{path}' after 30 seconds") + logger.error("SMB rename timed out.", extra={"operation": "rename_item"}) raise TimeoutError(f"SMB operation timed out while renaming: {path}") except OSError as e: error_str = str(e) @@ -1409,13 +1539,15 @@ async def rename_item(self, path: str, new_name: str) -> None: if "0xc0000035" in error_str: raise FileExistsError(f"An item named '{new_name}' already exists") from e logger.error( - f"Failed to rename '{path}': {type(e).__name__}: {e}", + "SMB rename failed.", + extra={"operation": "rename_item", "error_type": type(e).__name__}, exc_info=True, ) raise except Exception as e: logger.error( - f"Failed to rename '{path}': {type(e).__name__}: {e}", + "SMB rename failed.", + extra={"operation": "rename_item", "error_type": type(e).__name__}, exc_info=True, ) raise @@ -1436,7 +1568,7 @@ async def create_directory(self, path: str) -> None: """ smb_path = self._build_smb_path(path) - logger.debug(f"Creating directory: path='{path}' -> smb_path='{smb_path}'") + logger.debug("SMB directory creation started.", extra={"operation": "create_directory"}) try: pool = await get_connection_pool() @@ -1454,12 +1586,13 @@ async def create_directory(self, path: str) -> None: lambda: smbclient.mkdir(smb_path, **self._smb_auth_kwargs()), 30.0, smb_path=smb_path, + mutation_paths=(smb_path,), ) - logger.debug(f"Successfully created directory: '{path}'") + logger.debug("SMB directory creation completed.", extra={"operation": "create_directory"}) except asyncio.TimeoutError: - logger.error(f"Timeout creating directory '{path}' after 30 seconds") + logger.error("SMB directory creation timed out.", extra={"operation": "create_directory"}) raise TimeoutError(f"SMB operation timed out while creating directory: {path}") except OSError as e: error_str = str(e) @@ -1470,13 +1603,15 @@ async def create_directory(self, path: str) -> None: if "0xc0000034" in error_str or "No such file" in error_str: raise FileNotFoundError(f"Parent directory not found for: {path}") from e logger.error( - f"Failed to create directory '{path}': {type(e).__name__}: {e}", + "SMB directory creation failed.", + extra={"operation": "create_directory", "error_type": type(e).__name__}, exc_info=True, ) raise except Exception as e: logger.error( - f"Failed to create directory '{path}': {type(e).__name__}: {e}", + "SMB directory creation failed.", + extra={"operation": "create_directory", "error_type": type(e).__name__}, exc_info=True, ) raise @@ -1499,7 +1634,7 @@ async def create_file(self, path: str) -> None: """ smb_path = self._build_smb_path(path) - logger.debug(f"Creating file: path='{path}' -> smb_path='{smb_path}'") + logger.debug("SMB file creation started.", extra={"operation": "create_file"}) try: pool = await get_connection_pool() @@ -1522,12 +1657,13 @@ def _create_empty_file() -> None: _create_empty_file, 30.0, smb_path=smb_path, + mutation_paths=(smb_path,), ) - logger.debug(f"Successfully created file: '{path}'") + logger.debug("SMB file creation completed.", extra={"operation": "create_file"}) except asyncio.TimeoutError: - logger.error(f"Timeout creating file '{path}' after 30 seconds") + logger.error("SMB file creation timed out.", extra={"operation": "create_file"}) raise TimeoutError(f"SMB operation timed out while creating file: {path}") except OSError as e: error_str = str(e) @@ -1538,13 +1674,15 @@ def _create_empty_file() -> None: if "0xc0000034" in error_str or "No such file" in error_str: raise FileNotFoundError(f"Parent directory not found for: {path}") from e logger.error( - f"Failed to create file '{path}': {type(e).__name__}: {e}", + "SMB file creation failed.", + extra={"operation": "create_file", "error_type": type(e).__name__}, exc_info=True, ) raise except Exception as e: logger.error( - f"Failed to create file '{path}': {type(e).__name__}: {e}", + "SMB file creation failed.", + extra={"operation": "create_file", "error_type": type(e).__name__}, exc_info=True, ) raise @@ -1581,7 +1719,7 @@ async def get_file_size(self, path: str) -> int | None: error_str = str(e) if "0xc0000034" in error_str or "No such file" in error_str: raise FileNotFoundError(f"Path not found: {path}") from e - logger.error(f"Failed to get file size for '{path}': {e}") + logger.error("SMB file size could not be read.", extra={"operation": "get_file_size", "error_type": type(e).__name__}) raise # @@ -1609,7 +1747,7 @@ async def copy_item(self, source_path: str, dest_path: str, *, overwrite: bool = smb_src = self._build_smb_path(source_path) smb_dst = self._build_smb_path(dest_path) - logger.debug(f"Copying item: '{source_path}' -> '{dest_path}' (smb: '{smb_src}' -> '{smb_dst}')") + logger.debug("SMB copy started.", extra={"operation": "copy_item"}) try: pool = await get_connection_pool() @@ -1653,7 +1791,8 @@ def _copy_recursive(src: str, dst: str) -> None: # generator is fully consumed before deeper calls. children = [(entry.name, entry.path) for entry in smbclient.scandir(src, **self._smb_auth_kwargs())] for child_name, child_path in children: - _copy_recursive(child_path, f"{dst}\\{child_name}") + child_destination = f"{dst}\\{child_name}" + self._run_observed_operation(lambda: _copy_recursive(child_path, child_destination), (child_destination,)) # Restore directory timestamps after all children are # copied (adding children updates the directory mtime). smbclient.utime(dst, ns=(atime_ns, mtime_ns), **self._smb_auth_kwargs()) @@ -1666,12 +1805,13 @@ def _copy_recursive(src: str, dst: str) -> None: lambda: _copy_recursive(smb_src, smb_dst), 300.0, # Large copies may take time smb_path=smb_src, + mutation_paths=(smb_dst,), ) - logger.debug(f"Successfully copied: '{source_path}' -> '{dest_path}'") + logger.debug("SMB copy completed.", extra={"operation": "copy_item"}) except asyncio.TimeoutError: - logger.error(f"Timeout copying '{source_path}' after 300 seconds") + logger.error("SMB copy timed out.", extra={"operation": "copy_item"}) raise TimeoutError(f"SMB operation timed out while copying: {source_path}") except OSError as e: error_str = str(e) @@ -1682,13 +1822,15 @@ def _copy_recursive(src: str, dst: str) -> None: if "0xc0000035" in error_str: raise FileExistsError(f"Destination already exists: {dest_path}") from e logger.error( - f"Failed to copy '{source_path}' -> '{dest_path}': {type(e).__name__}: {e}", + "SMB copy failed.", + extra={"operation": "copy_item", "error_type": type(e).__name__}, exc_info=True, ) raise except Exception as e: logger.error( - f"Failed to copy '{source_path}' -> '{dest_path}': {type(e).__name__}: {e}", + "SMB copy failed.", + extra={"operation": "copy_item", "error_type": type(e).__name__}, exc_info=True, ) raise @@ -1717,7 +1859,7 @@ async def move_item(self, source_path: str, dest_path: str, *, overwrite: bool = smb_src = self._build_smb_path(source_path) smb_dst = self._build_smb_path(dest_path) - logger.debug(f"Moving item: '{source_path}' -> '{dest_path}' (smb: '{smb_src}' -> '{smb_dst}')") + logger.debug("SMB move started.", extra={"operation": "move_item"}) try: pool = await get_connection_pool() @@ -1751,12 +1893,13 @@ async def move_item(self, source_path: str, dest_path: str, *, overwrite: bool = ), 30.0, smb_path=smb_src, + mutation_paths=(smb_src, smb_dst), ) - logger.debug(f"Successfully moved: '{source_path}' -> '{dest_path}'") + logger.debug("SMB move completed.", extra={"operation": "move_item"}) except asyncio.TimeoutError: - logger.error(f"Timeout moving '{source_path}' after 30 seconds") + logger.error("SMB move timed out.", extra={"operation": "move_item"}) raise TimeoutError(f"SMB operation timed out while moving: {source_path}") except OSError as e: error_str = str(e) @@ -1767,13 +1910,15 @@ async def move_item(self, source_path: str, dest_path: str, *, overwrite: bool = if "0xc0000035" in error_str: raise FileExistsError(f"Destination already exists: {dest_path}") from e logger.error( - f"Failed to move '{source_path}' -> '{dest_path}': {type(e).__name__}: {e}", + "SMB move failed.", + extra={"operation": "move_item", "error_type": type(e).__name__}, exc_info=True, ) raise except Exception as e: logger.error( - f"Failed to move '{source_path}' -> '{dest_path}': {type(e).__name__}: {e}", + "SMB move failed.", + extra={"operation": "move_item", "error_type": type(e).__name__}, exc_info=True, ) raise @@ -1819,7 +1964,7 @@ async def write_file_from_stream( chunk_write_timeout_s: float = 60.0 smb_path = self._build_smb_path(path) - logger.debug(f"write_file_from_stream: path='{path}' -> smb_path='{smb_path}'") + logger.debug("SMB stream write started.", extra={"operation": "write_file_from_stream"}) bytes_written = 0 replaced = False @@ -1854,6 +1999,7 @@ async def write_file_from_stream( ), 15.0, smb_path=smb_path, + mutation_paths=(smb_path,), ) target_may_have_changed = True @@ -1880,6 +2026,7 @@ async def write_file_from_stream( file_handle.close, 5.0, smb_path=smb_path, + mutation_paths=(smb_path,), ) # Preserve the original modification timestamp inside @@ -1897,11 +2044,11 @@ async def write_file_from_stream( smb_path=smb_path, ) - logger.debug(f"write_file_from_stream: wrote {bytes_written} bytes to '{path}'") + logger.debug("SMB stream write completed.", extra={"operation": "write_file_from_stream"}) return TargetWriteResult(bytes_written, replaced=replaced) except asyncio.TimeoutError as exc: - logger.error(f"Timeout during write_file_from_stream for '{path}'") + logger.error("SMB stream write timed out.", extra={"operation": "write_file_from_stream"}) timeout_error = TimeoutError(f"SMB operation timed out while writing: {path}") if target_may_have_changed: raise TargetWriteFailure(timeout_error, bytes_written) from exc @@ -1914,12 +2061,16 @@ async def write_file_from_stream( raise TargetExistsBeforeContent(f"Destination already exists: {path}") from e if "0xc0000043" in error_str or "being used by another process" in error_str: raise IOError(f"File is locked and cannot be written: {path}") from e - logger.error(f"Failed write_file_from_stream '{path}': {type(e).__name__}: {e}", exc_info=True) + logger.error( + "SMB stream write failed.", extra={"operation": "write_file_from_stream", "error_type": type(e).__name__}, exc_info=True + ) raise except Exception as e: if target_may_have_changed: raise TargetWriteFailure(e, bytes_written) from e - logger.error(f"Failed write_file_from_stream '{path}': {type(e).__name__}: {e}", exc_info=True) + logger.error( + "SMB stream write failed.", extra={"operation": "write_file_from_stream", "error_type": type(e).__name__}, exc_info=True + ) raise async def stage_and_commit_new_file_from_stream( @@ -1961,12 +2112,13 @@ async def remove_stage() -> None: lambda: smbclient.remove(smb_stage_path, **self._smb_auth_kwargs()), SMB_DELETE_TIMEOUT_SECONDS, smb_path=smb_stage_path, + mutation_paths=(smb_stage_path,), ) except Exception as cleanup_error: logger.warning( - "Failed to discard SMB transfer stage '%s': %s", - stage_path, - cleanup_error, + "SMB transfer stage cleanup failed.", + extra={"operation": "discard_transfer_stage", "error_type": type(cleanup_error).__name__}, + exc_info=True, ) try: @@ -1989,6 +2141,7 @@ async def remove_stage() -> None: ), SMB_FILE_OPEN_TIMEOUT_SECONDS, smb_path=smb_stage_path, + mutation_paths=(smb_stage_path,), ) try: async for chunk in stream: @@ -2041,9 +2194,10 @@ async def remove_stage() -> None: lambda: commit_stage(smb_stage_path, smb_path, **self._smb_auth_kwargs()), SMB_FILE_OPEN_TIMEOUT_SECONDS, smb_path=smb_path, + mutation_paths=(smb_stage_path, smb_path), ) committed = True - logger.debug("Committed staged SMB transfer: '%s'", path) + logger.debug("Staged SMB transfer committed.", extra={"operation": "commit_transfer_stage"}) return bytes_written except OSError as error: error_text = str(error) diff --git a/backend/app/storage/smb_errors.py b/backend/app/storage/smb_errors.py index 0bcb68906..a01c1e1a3 100644 --- a/backend/app/storage/smb_errors.py +++ b/backend/app/storage/smb_errors.py @@ -6,6 +6,31 @@ from smbprotocol.exceptions import SMBOSError, SMBResponseException from smbprotocol.header import NtStatus +SMB_RECOVERY_RETRY_SECONDS = 2 + + +class SMBOperationTimeout(TimeoutError): + """The caller deadline elapsed; dispatched work can still be active.""" + + retry_after_seconds = SMB_RECOVERY_RETRY_SECONDS + + +class SMBConnectionRetired(RuntimeError): + """The old generation is retained for cleanup and cannot accept work.""" + + retry_after_seconds = SMB_RECOVERY_RETRY_SECONDS + + +class SMBConnectionCapacityError(SMBConnectionRetired): + """Connection recovery capacity is reserved by existing work.""" + + +class SMBMutationUncertain(SMBConnectionRetired): + """A dispatched mutation lost confirmation; automatic replay is unsafe.""" + + def __init__(self) -> None: + super().__init__("SMB change confirmation was lost. Check the source before retrying; the change may already be applied.") + def translate_smb_target_error(error: Exception, path: str) -> Exception: """Keep connection failures distinct from missing or inaccessible items.""" diff --git a/backend/app/storage/smb_pool.py b/backend/app/storage/smb_pool.py index ec561f479..90636a860 100644 --- a/backend/app/storage/smb_pool.py +++ b/backend/app/storage/smb_pool.py @@ -13,11 +13,13 @@ """ import asyncio +import hashlib import logging import threading +import uuid from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from functools import partial from time import monotonic @@ -26,9 +28,18 @@ import smbclient from app.services.system_settings import get_smbclient_policy_kwargs +from app.storage.smb_errors import SMBConnectionCapacityError, SMBConnectionRetired, SMBOperationTimeout +from app.utils.worker_wait import wait_for_worker logger = logging.getLogger(__name__) +MAX_CONNECTION_GENERATIONS = 64 +MAX_SOURCE_GENERATIONS = 2 +MAX_CONNECTION_SETUPS = 8 +MAX_CONNECTION_ADMISSIONS = 128 +MAX_SOURCE_ADMISSIONS = 16 +CONNECTION_SETUP_TIMEOUT_SECONDS = 30.0 + _connection_context_caches: dict[str, dict[str, Any]] = {} _connection_context_lock = threading.RLock() @@ -58,6 +69,11 @@ class PooledConnection: reference_count: int connection_cache: dict[str, Any] retire_when_idle: bool = False + source_key: str = "" + setup: asyncio.Task[None] | None = None + resetting: bool = False + abort_task: asyncio.Task[None] | None = None + generation_id: str = field(default_factory=lambda: uuid.uuid4().hex) class SMBConnectionPool: @@ -90,6 +106,37 @@ def __init__( self._cleanup_interval = cleanup_interval self._cleanup_task: Optional[asyncio.Task[None]] = None self._legacy_connection_caches: dict[tuple[str, int, str, str], dict[str, Any]] = {} + self._background_tasks: set[asyncio.Task[None]] = set() + self._closing = False + + def _retain_task(self, task: asyncio.Task[None]) -> None: + self._background_tasks.add(task) + + def completed(result: asyncio.Task[None]) -> None: + self._background_tasks.discard(result) + if not result.cancelled(): + result.exception() + + task.add_done_callback(completed) + + async def _register(self, pool_key: int, conn: PooledConnection, password: str) -> None: + try: + await asyncio.get_running_loop().run_in_executor( + None, + lambda: smbclient.register_session( + conn.host, + username=conn.username, + password=password, + port=conn.port, + connection_cache=conn.connection_cache, + **get_smbclient_policy_kwargs(), + ), + ) + except BaseException: + conn.retire_when_idle = True + raise + finally: + await self._release_connection_reference(pool_key, conn.host, conn.port, conn.share_name) # # _get_pool_key @@ -136,41 +183,31 @@ async def get_connection( connection_cache = self._legacy_connection_caches.setdefault(legacy_key, {}) pool_key = self._get_pool_key(connection_cache) - # Acquire connection async with self._lock: - if pool_key in self._connections: - # Reuse existing connection - conn = self._connections[pool_key] - if conn.retire_when_idle: - raise RuntimeError("SMB connection context is being retired") - conn.reference_count += 1 - conn.last_used_monotonic = monotonic() - logger.debug(f"Reusing pooled connection: {host}:{port}/{share_name} (refs={conn.reference_count})") - else: - # Create new connection - logger.debug(f"Creating new pooled connection: {host}:{port}/{share_name}") - - # Register session with smbclient (establishes connection) - try: - await asyncio.get_event_loop().run_in_executor( - None, - lambda: smbclient.register_session( - host, - username=username, - password=password, - port=port, - connection_cache=connection_cache, - **get_smbclient_policy_kwargs(), - ), - ) - except Exception as e: - logger.error( - f"Failed to create SMB connection to {host}:{port}: {e}", - exc_info=True, - ) - raise - - # Add to pool + if self._closing: + raise SMBConnectionRetired("SMB connection pool is shutting down. Reconnect after the service restarts.") + existing = self._connections.get(pool_key) + with _connection_context_lock: + source_key = next( + (key for key, cache in _connection_context_caches.items() if cache is connection_cache), + existing.source_key if existing is not None else repr((host.lower(), port, username, share_name)), + ) + source_connections = [entry for entry in self._connections.values() if entry.source_key == source_key] + required_references = 1 + (existing is None) + if ( + sum(entry.reference_count for entry in self._connections.values()) + required_references > MAX_CONNECTION_ADMISSIONS + or sum(entry.reference_count for entry in source_connections) + required_references > MAX_SOURCE_ADMISSIONS + ): + raise SMBConnectionCapacityError("SMB work capacity is busy. Wait briefly and retry; active work is retained.") + conn = self._connections.get(pool_key) + if conn is None: + if ( + len(self._connections) >= MAX_CONNECTION_GENERATIONS + or len(source_connections) >= MAX_SOURCE_GENERATIONS + or sum(entry.setup is not None and not entry.setup.done() for entry in self._connections.values()) + >= MAX_CONNECTION_SETUPS + ): + raise SMBConnectionCapacityError("SMB recovery capacity is busy. Wait for retained workers to finish, then retry.") conn = PooledConnection( host=host, port=port, @@ -180,13 +217,31 @@ async def get_connection( last_used_monotonic=monotonic(), reference_count=1, connection_cache=connection_cache, + source_key=source_key, ) self._connections[pool_key] = conn - - logger.debug(f"SMB connection pooled: {host}:{port}/{share_name}") + conn.setup = asyncio.create_task(self._register(pool_key, conn, password)) + self._retain_task(conn.setup) + if conn.retire_when_idle: + raise SMBConnectionRetired("SMB connection context is being retired. Retry with a new connection generation.") + conn.reference_count += 1 + conn.last_used_monotonic = monotonic() try: - # Yield control to caller (connection is ready) + if conn.setup is not None: + try: + await wait_for_worker(conn.setup, CONNECTION_SETUP_TIMEOUT_SECONDS) + except TimeoutError as error: + await self.invalidate_connection_cache(connection_cache, reason="setup_timeout", abort=True) + raise SMBOperationTimeout( + "SMB connection setup timed out. Wait briefly and reconnect; setup cleanup is retained." + ) from error + except asyncio.CancelledError: + if conn.reference_count == 2: + await self.invalidate_connection_cache(connection_cache, reason="setup_cancelled") + raise + if conn.retire_when_idle: + raise SMBConnectionRetired("SMB connection was retired during setup. Reconnect and retry.") yield finally: @@ -211,7 +266,7 @@ async def retain_connection_until_future_complete( conn.last_used_monotonic = monotonic() def release_when_complete(_future: asyncio.Future[Any]) -> None: - asyncio.create_task(self._release_connection_reference(pool_key, conn.host, conn.port, conn.share_name)) + self._retain_task(asyncio.create_task(self._release_connection_reference(pool_key, conn.host, conn.port, conn.share_name))) operation_future.add_done_callback(release_when_complete) @@ -226,20 +281,38 @@ async def _release_connection_reference(self, pool_key: int, host: str, port: in conn.reference_count -= 1 conn.last_used_monotonic = monotonic() - logger.debug(f"Released pooled connection: {host}:{port}/{share_name} (refs={conn.reference_count})") - if conn.reference_count == 0 and conn.retire_when_idle: - connection_to_reset = self._connections.pop(pool_key) + if conn.reference_count == 0 and conn.retire_when_idle and not conn.resetting: + conn.resetting = True + connection_to_reset = conn if connection_to_reset is not None: - await asyncio.get_event_loop().run_in_executor( - None, - partial( - smbclient.reset_connection_cache, - fail_on_error=False, - connection_cache=connection_to_reset.connection_cache, - ), + cleanup = asyncio.create_task(self._reset_connection(pool_key, connection_to_reset)) + self._retain_task(cleanup) + await wait_for_worker(cleanup) + + async def _reset_connection(self, pool_key: int, conn: PooledConnection) -> None: + try: + await asyncio.get_running_loop().run_in_executor( + None, partial(smbclient.reset_connection_cache, fail_on_error=False, connection_cache=conn.connection_cache) ) + except Exception as error: + logger.warning( + "SMB generation cleanup failed. Check source access before reconnecting.", + extra={ + "event": "smb_cleanup_failed", + "operation": "reset_connection_cache", + "stage": "cleanup", + "source_id": hashlib.sha256(conn.source_key.encode()).hexdigest()[:16], + "generation_id": conn.generation_id, + "error_type": type(error).__name__, + }, + exc_info=True, + ) + finally: + async with self._lock: + if self._connections.get(pool_key) is conn: + self._connections.pop(pool_key) # # cleanup_idle_connections @@ -249,35 +322,13 @@ async def cleanup_idle_connections(self) -> None: async with self._lock: now = monotonic() - to_remove = [] - - for pool_key, conn in self._connections.items(): - # Only remove if not actively in use - if conn.reference_count == 0: - idle_time = now - conn.last_used_monotonic - if idle_time > self._max_idle_time.total_seconds(): - to_remove.append(pool_key) - - # Remove idle connections - for pool_key in to_remove: - conn = self._connections[pool_key] - logger.debug( - f"Removing idle connection: {conn.host}:{conn.port}/{conn.share_name} (idle for {now - conn.last_used_monotonic:.0f}s)" - ) - - # Disconnect only this backend's private smbclient cache. - try: - await asyncio.get_event_loop().run_in_executor( - None, - partial(smbclient.reset_connection_cache, fail_on_error=False, connection_cache=conn.connection_cache), - ) - except Exception as e: - logger.warning(f"Error deleting session for {conn.host}:{conn.port}: {e}") - - del self._connections[pool_key] - - if to_remove: - logger.debug(f"Cleaned up {len(to_remove)} idle connection(s), {len(self._connections)} remaining") + expired = [ + conn.connection_cache + for conn in self._connections.values() + if conn.reference_count == 0 and not conn.resetting and now - conn.last_used_monotonic > self._max_idle_time.total_seconds() + ] + for cache in expired: + await self.invalidate_connection_cache(cache, reason="idle_release") # # start_cleanup_task @@ -297,8 +348,12 @@ async def cleanup_loop() -> None: except asyncio.CancelledError: logger.info("Connection pool cleanup task cancelled") break - except Exception as e: - logger.error(f"Error in cleanup task: {e}", exc_info=True) + except Exception as error: + logger.error( + "SMB pool cleanup failed. Check source access before reconnecting.", + extra={"event": "smb_pool_cleanup_failed", "stage": "cleanup", "error_type": type(error).__name__}, + exc_info=True, + ) self._cleanup_task = asyncio.create_task(cleanup_loop()) logger.debug("Started SMB connection pool cleanup task") @@ -324,21 +379,10 @@ async def close_all(self) -> None: """Close all pooled connections (for shutdown).""" async with self._lock: - loop = asyncio.get_event_loop() - for pool_key, conn in self._connections.items(): - try: - logger.info(f"Closing connection: {conn.host}:{conn.port}/{conn.share_name}") - # Run in executor to avoid blocking the event loop during - # the SMB disconnect handshake. - await loop.run_in_executor( - None, - partial(smbclient.reset_connection_cache, fail_on_error=False, connection_cache=conn.connection_cache), - ) - except Exception as e: - logger.warning(f"Error closing connection {conn.host}:{conn.port}: {e}") - - self._connections.clear() - logger.info("All SMB connections closed") + self._closing = True + caches = [conn.connection_cache for conn in self._connections.values()] + for cache in caches: + await self.invalidate_connection_cache(cache, reason="shutdown") async def invalidate_connection( self, @@ -348,6 +392,8 @@ async def invalidate_connection( share_name: str, connection_cache: dict[str, Any] | None = None, reason: str | None = None, + *, + abort: bool = False, ) -> None: """Remove a pooled connection and delete its underlying smbclient session.""" @@ -357,51 +403,100 @@ async def invalidate_connection( if connection_cache is None: return - await self.invalidate_connection_cache(connection_cache, reason=reason) + await self.invalidate_connection_cache(connection_cache, reason=reason, abort=abort) + + async def _abort_generation(self, pool_key: int, conn: PooledConnection, transports: tuple[Any, ...]) -> None: + """Keep abort capacity owned until every public disconnect call exits.""" + try: + for transport in transports: + try: + await asyncio.to_thread(transport.disconnect, close=False) + except Exception as error: + logger.warning( + "SMB transport abort failed. Retry when capacity is available; restart the service if work remains blocked.", + extra={ + **self.connection_diagnostics(conn.connection_cache), + "event": "smb_abort_failed", + "operation": "disconnect", + "stage": "abort", + "error_type": type(error).__name__, + }, + exc_info=True, + ) + finally: + await self._release_connection_reference(pool_key, conn.host, conn.port, conn.share_name) - async def invalidate_connection_cache(self, connection_cache: dict[str, Any], reason: str | None = None) -> None: + async def invalidate_connection_cache( + self, connection_cache: dict[str, Any], reason: str | None = None, *, abort: bool = False + ) -> None: """Close a private cache after its active pool leases have finished.""" pool_key = self._get_pool_key(connection_cache) async with self._lock: conn = self._connections.get(pool_key) + with _connection_context_lock: + for source_key, cache in list(_connection_context_caches.items()): + if cache is connection_cache: + _connection_context_caches.pop(source_key) + if conn is not None and conn.resetting: + return + was_retired = conn.retire_when_idle if conn is not None else False + if conn is not None and abort and conn.abort_task is None: + conn.retire_when_idle = True + transports = tuple(connection_cache.copy().values()) + conn.reference_count += 1 + conn.abort_task = asyncio.create_task(self._abort_generation(pool_key, conn, transports)) + self._retain_task(conn.abort_task) if conn is not None and conn.reference_count > 0: conn.retire_when_idle = True - logger.warning( - "Deferring invalidation of active pooled connection: %s:%s/%s%s", - conn.host, - conn.port, - conn.share_name, - f" ({reason})" if reason else "", - ) + if not was_retired: + logger.warning( + "SMB generation retired; cleanup waits for retained leases.", + extra={ + "event": "smb_generation_retired", + "stage": "retirement", + "source_id": hashlib.sha256(conn.source_key.encode()).hexdigest()[:16], + "generation_id": conn.generation_id, + "retained_worker_count": conn.reference_count, + }, + ) return if conn is not None: - self._connections.pop(pool_key) + conn.retire_when_idle = True + conn.resetting = True - try: - if conn is not None: - logger.warning( - "Invalidating pooled connection: %s:%s/%s%s", - conn.host, - conn.port, - conn.share_name, - f" ({reason})" if reason else "", - ) - await asyncio.get_event_loop().run_in_executor( + if conn is not None: + cleanup = asyncio.create_task(self._reset_connection(pool_key, conn)) + self._retain_task(cleanup) + await wait_for_worker(cleanup) + else: + future = asyncio.get_running_loop().run_in_executor( None, partial(smbclient.reset_connection_cache, fail_on_error=False, connection_cache=connection_cache), ) - except Exception as e: - if conn is None: - logger.warning("Error invalidating unpooled SMB cache: %s", e) - else: - logger.warning(f"Error invalidating connection {conn.host}:{conn.port}: {e}") + await wait_for_worker(future) # # get_stats # + def connection_diagnostics(self, connection_cache: dict[str, Any]) -> dict[str, str | int]: + """Copy opaque ownership fields on the pool's event-loop thread.""" + connection = self._connections.get(self._get_pool_key(connection_cache)) + if connection is None: + return {} + return { + "source_id": hashlib.sha256(connection.source_key.encode()).hexdigest()[:16], + "generation_id": connection.generation_id, + "retained_worker_count": connection.reference_count, + } + + def generation_aborting(self, connection_cache: dict[str, Any]) -> bool: + """Read the acquired generation's abort state on the event-loop thread.""" + connection = self._connections.get(self._get_pool_key(connection_cache)) + return connection is not None and connection.abort_task is not None + def get_stats(self) -> dict[str, int]: """ Get statistics about the connection pool. diff --git a/backend/app/utils/worker_wait.py b/backend/app/utils/worker_wait.py new file mode 100644 index 000000000..509499489 --- /dev/null +++ b/backend/app/utils/worker_wait.py @@ -0,0 +1,14 @@ +"""Wait for owned workers without creating abandoned shield futures.""" + +import asyncio +from typing import TypeVar + +WorkerResult = TypeVar("WorkerResult") + + +async def wait_for_worker(future: asyncio.Future[WorkerResult], timeout: float | None = None) -> WorkerResult: + """Leave cancellation and late-result disposal to the worker's owner.""" + completed, _ = await asyncio.wait((future,), timeout=timeout) + if not completed: + raise TimeoutError("Worker is still running after the caller timeout") + return future.result() diff --git a/backend/requirements-dev.lock.txt b/backend/requirements-dev.lock.txt index 289693181..720a1136d 100644 --- a/backend/requirements-dev.lock.txt +++ b/backend/requirements-dev.lock.txt @@ -16,9 +16,9 @@ annotated-types==0.8.0 \ --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \ --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 # via pydantic -anyio==4.15.1 \ - --hash=sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101 \ - --hash=sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94 +anyio==4.16.0 \ + --hash=sha256:59bb03251907983d1603d27c3087ddb1e93299ea2163adcdba37899a4fb12ffb \ + --hash=sha256:c66ae634e0b8228396b801741beb3294326d4b6102f7bba4041c0dfb58c29026 # via # httpx # httpx2 @@ -996,9 +996,9 @@ pexpect==4.9.0 \ --hash=sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523 \ --hash=sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f # via ipython -platformdirs==4.13.5 \ - --hash=sha256:05831adf87d392760f4b5c3a0a95579ab5a10b18b9b9bd43680dcb47544788a0 \ - --hash=sha256:53d3ec4280b815c1e0b02edad7f9ee8fda1ba9c0865dd9c2e6e63849ce82fc1e +platformdirs==4.14.3 \ + --hash=sha256:021add1ab113483edbcd4694f5fbb2d688b893ea30d35e1155bb371cf4be0be8 \ + --hash=sha256:cba2d05fe5e18b26b1fa6f376b8e6234c012abe1f8696c486fface7d0054e931 # via black pluggy==1.7.0 \ --hash=sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec \ @@ -1604,9 +1604,9 @@ stack-data==0.6.3 \ --hash=sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9 \ --hash=sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695 # via ipython -starlette==1.9.0 \ - --hash=sha256:3d931de246ed870e35ce605974440816ef895e592f255d1396cfdf191d4c4318 \ - --hash=sha256:fd3f159936e882d06c1108f84688f7cda2cf85fc6a592a50edbd175e882c2ddc +starlette==1.10.0 \ + --hash=sha256:478c0ab3b4ced6e82719849c118fa406f0278a3499c1a898cd15cd31bdc38ce4 \ + --hash=sha256:b8399098d4c1e3522734f7235dc79b1fc967f96cf139c81e0fa30479e0685f9b # via fastapi traitlets==5.16.1 \ --hash=sha256:ed900c2b631aa3a112811139fa97b8d2c3bad5e989656bba4b7e52c7852c18c1 \ @@ -1634,6 +1634,7 @@ typing-extensions==4.16.0 \ # opentelemetry-api # pydantic # pydantic-core + # pydantic-settings # sqlalchemy # sqlmodel # typing-inspection @@ -1746,190 +1747,190 @@ watchfiles==1.3.0 \ --hash=sha256:ec3cd4bb181a7b6329134204c2277c7144ab5f62cce9c5335d6f901ee7ce8d6a \ --hash=sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3 # via uvicorn -wcwidth==0.9.3 \ - --hash=sha256:16d09df90b3a8f799ea14c726a41d9b906c3104f95f46d158e1ebe45cf4d547b \ - --hash=sha256:2f875a4c9e62df2ecfa70d99b24c551f2245609858e179c3a40e74631b2d08a4 \ - --hash=sha256:3b119ef60177a92b4f298b80e98464c2922a1fba3ed77fe9a2bcb69edf1b9af4 \ - --hash=sha256:3e3e31d6c17d6da3051f7eb854d5e4bebdc52a08a7a52adc2da6f15af51304e8 \ - --hash=sha256:457f986e4867111cb037ccbf0d97ae100b1ecad030d43a020e7207f1e29649d5 \ - --hash=sha256:52a13869704d43a4add59c68ebc892198e53063883114783bf0b7457e250e3f4 \ - --hash=sha256:537ea00b6792bb243cd09582322c54dbc848879e5d34ea4c5fee0147529685ab \ - --hash=sha256:569a0cdb826d3097b6dbacfaa45aa0002d72a2ba6fa8a63251ea43ac4dd7b6a4 \ - --hash=sha256:5d3b0b6c37f40e8d8298941ec373a426a892bb9a514328abcc9226cdc60ae6eb \ - --hash=sha256:658dcbce546e7f28ee361f88e4a8e591dd56f46bff6084e11b4ad8310ed99946 \ - --hash=sha256:6f3e855d3a09af8b7b6d56e76bc270681032f8a37d527a8d5707a441958e3730 \ - --hash=sha256:70a2ca239b85a219324968993c76c117fe423d4e1a7c9d860c999c66192f2570 \ - --hash=sha256:7a95d8c784cbccbbcb808eaadfd156cd282b2bdd95e89b32505e3443381ce61d \ - --hash=sha256:80557db1f77148b78e1b3c2a9cc4ebc4feabef42aaf69a555565057031c32367 \ - --hash=sha256:9470169fcb4e5340269175ebb3642dd5bfc8f82da8c86f29fc1fefc6c2b9bc1b \ - --hash=sha256:9ab8bfee107e08de09e15f9f6afb9fe0f64c8d64fd296d0d8557bafe71ea3f93 \ - --hash=sha256:9e1cbe18c18d152e1e68e4e2561dab6c2bc988f8fd337a1fb2e1e0809c3fa568 \ - --hash=sha256:a44acbb23b525070a0b8c343dd7cab7a4020aff73b38804f7a66d6a66e16e3fc \ - --hash=sha256:a7c0405a3bf3c564e481d6b2d55c99cc231bb68cf8f4a0995944bbce5ebf1a5d \ - --hash=sha256:aab90ab489850f87633922c3064f6d0442d8f6ae5407a62f82bdb1f424179c05 \ - --hash=sha256:c12abd75ffcbad417e7770a6feb82413c102ccce5c435068c39a82e2218b00b3 \ - --hash=sha256:c1bbc8ebf133422cadfefea95a9f89d6c9e9a1ae19930bef7862a9b59b20bbad \ - --hash=sha256:c4531d93344441a7948e665854f0c40e3a8342ee106436d1531efa7a9fa7641e \ - --hash=sha256:dfe7cfbd4cb59db9fe0931bd6091ab4a95b3acc5d56bfb50fa9fef0ccba5dc5c \ - --hash=sha256:e3c9069c54a2da0bf4f6c7e65a735cb1da0f47601a15cb5fef7e4b6ffca4cf60 \ - --hash=sha256:e9362be098c43d076aa43754face3cc0180fa81cea9ba304240b15490f2617e8 \ - --hash=sha256:eef39f3260991d8767189d296f0116fa4b45ad9c3a9a16d5af9187fb66b27564 \ - --hash=sha256:f11789e520ade8017fa93b01a4b8985571bb433eaefc2f857b57e36875e2e1d8 \ - --hash=sha256:f2f943c4ef14e9baba730740265f4e5bf5fc8473550e36cd92828cd5663561a6 +wcwidth==0.9.4 \ + --hash=sha256:093469e01d881bff3db93ff3f56c364dc87c441ea01f9f41c22de0c0bdf5c6ef \ + --hash=sha256:12786620375dc83fabf4b2b2aa52416fef2bc6094522ea29c8a53c75b0dfe9b6 \ + --hash=sha256:14410eb73178cc492aa9bf22f7c12e3bf930e5470b7220cf6f530efbfa2e03f3 \ + --hash=sha256:17619066f95c10f44d7ae08ee456602456bc66cc9d5e91966ec73d799acebc5f \ + --hash=sha256:29c0685f5f5f8815b88308fdcbe2cd12a92996a21a5b1e18352d165174ef7a6d \ + --hash=sha256:2b58fc8cf2de1bf9cdb68b8beaa5be198742414a7c8cd97aa51ec039783ffa49 \ + --hash=sha256:2cca7b5d0b9d0ae49068d3ce806e965326a987f555b62eaa322223396e10d01b \ + --hash=sha256:4831662c396dd4c59a016ec53c0ac96738006d653a00482777d9a2e2ea323d62 \ + --hash=sha256:79e09e9824c88f5e275ca99e921685567a5c5c1bb6165c16e27ddd1adab33a95 \ + --hash=sha256:87e4101789415a6cf55ec4c53eceeb4782c2654c88692941cb588f19dca1959f \ + --hash=sha256:8f892713ab5795d3abae181e9c02db0eb24de4084f9ee080535369d3d98d7bb5 \ + --hash=sha256:901eace6d2dd25ebb917d0ce5db18e5c537e1504c905cf32f0d006c12098b05d \ + --hash=sha256:9c4b0dd04cca520792100f6110d10be262c94b285a281d1759c551f159214633 \ + --hash=sha256:9e5090d253f5df99659cddfd3ed90dd21f6ff51989b0421331a63fb2681cbe27 \ + --hash=sha256:a1526cd25c6420e5153bb7d764c7509e692215ee3ad9b725db1ad665d47eae09 \ + --hash=sha256:a8c9ce14c6b4a499e61de6de7e9e456de11be8bc991554e2ab2ed4feb40b9664 \ + --hash=sha256:ab1e5190fb82476e75fbf963e9d59b4fb1c1e1f4ced82069a0c989016aa959f7 \ + --hash=sha256:b185c8b927b23af2721be1783499d1cfa5c028b098b0858951b4ae224477f1c2 \ + --hash=sha256:b52f265a694dda6b3ad9050c8bd89158ad519109ef7c5eb110b906cc49f25763 \ + --hash=sha256:b7969c9aa258d536df54c8ac8af5cad0c74a6268c9972e1fb60a5f29feffb66d \ + --hash=sha256:befbf46467100e43fd8c8cc0c031a0105f13f5418c3be95fdcf0c47214487967 \ + --hash=sha256:c36954849aafae1092d6f9de9548c5280c1d6841637a27ca288b2241c802c4ab \ + --hash=sha256:d68f09093c9bec9226a52edc47487042ed4f64c5df91d06f00419495071c0279 \ + --hash=sha256:d76b7c715c5ca0103b28915f699f1020ae21f06f1d22cdd85a7a97f7b1536eed \ + --hash=sha256:d8905f1390d828d745fdd1904374c90386d902d3cfa9ee88e9875099f75a4e17 \ + --hash=sha256:e05458491759aa42b7b85f36345b3e6df082d6d2d9bf616299668fd598e0209d \ + --hash=sha256:f279f32c8e206a9092fa350e53ca0c2bd8072e45b1c9fd31bb6d476b59b591c4 \ + --hash=sha256:f28d77153303450ee13ebbe888297e75cf532c754555b6cabafc9bdda16c421c \ + --hash=sha256:fd8d57d3cdfe570e3265c8fab1885c24e34ef40889c0759c0c304a19e9835954 # via prompt-toolkit -websockets==17.3 \ - --hash=sha256:018fab26140ce221c3853454a608e4e7004f4f10641cb97819dcd5b917bb2e99 \ - --hash=sha256:07246d4f0293ef0b87eff3d7dd88c1f03dd9f678fa39f6de87b2af8504e7e0de \ - --hash=sha256:078d1d41f45ee34eed5a4f3bbe7c588caecbdd6e12dfca73e8c1b0e803773793 \ - --hash=sha256:0824e6ecee838cc28e6481062574c7fde49f9a2cedfacd36f0e91cb4b37fd6fe \ - --hash=sha256:09f007b2150eff015b2109f006e2ac2012d376f41486defa88ae78a776b00cff \ - --hash=sha256:0a0bcf66289b9d017c175562c35c5304b5cc3ed56aded994ae30b0a9654ba405 \ - --hash=sha256:0a657b51e4a5f4ac577c11e706ff9a884ea5aaf3e2666bae283a251c63e66acb \ - --hash=sha256:0c63ad657695e8f1a48624754cb6b464118ac50d4c1d477c26461c98390f0a62 \ - --hash=sha256:0d20d6ea5aabbeba2e054d33e405192ef75dc12947bbd00ec97d23b14969d325 \ - --hash=sha256:0e65260a1b1d7b81651f47343d836de70b88b02358ae3658efa6382104ec37b1 \ - --hash=sha256:0f78bb2e82fe88912c9d7b25c98e57a9b08655579af89f164fd4c0657a075d62 \ - --hash=sha256:10b24e836ee0233ca078e1244f6ba40343a22127a9a736f46a7e7b18de2cc711 \ - --hash=sha256:13cffaedde6e06183860fc92d88fe1d190abda73837fe4c14f5d4803c472f5dd \ - --hash=sha256:13eace1b315345ebac5b27821f5dcce24e5cf9976ab18f79e00a59bdd67e5d19 \ - --hash=sha256:15decc0fe293d9e3f924bd483f3d76f3d0ff26845112a470e3f8842fec2c82a1 \ - --hash=sha256:1c7dd93a1c9f89297471493674b1fc1d9467cafd45374f2172213046bdb3a329 \ - --hash=sha256:1dae5cd6c92d4440b15f20aa2987baa73c47ea14baa0fec8536a542e82e0ab33 \ - --hash=sha256:1db2604e6aedb7a2cb2f6e0575d6c9a852ccac8239703a930ac83b63309177a5 \ - --hash=sha256:2129c9a5fc05a230ce088745a790af073e582b584badc7b05db067ee326f56d7 \ - --hash=sha256:23046098ebf844753cfd211033624a8247520e5bf2194c24cb1a8c222917acff \ - --hash=sha256:23cb6f6d64e3a389bb8ba150f947f9a6c8c0aca383e814ca3c5cffab738d5646 \ - --hash=sha256:26838e2d95d7004c52e8c7b6b51676c872093a3b55a497824f899116a15a8a55 \ - --hash=sha256:2789d203babb6ceb2770aadc2394c1f99263ec37fba57a93064f9a8ad899e8ff \ - --hash=sha256:28d1935c16782a1e3650c9baa79069b016fff739e292e4cb14bbb830d6930491 \ - --hash=sha256:29b01520c39ef2f23c88b002f11e6ef4165675f6c92f5f3578fd587d69b5a5fc \ - --hash=sha256:2a377c7132b1e69be7a50d7434d7f26f69e93c70166ebb6d8f8cabf802ac4506 \ - --hash=sha256:2b06213c8e1ce93b18d0476d7a5e647436c8f23d099888c9065e930e80f4bed5 \ - --hash=sha256:2c460aeb12c2bd1c204944d9e9199595bf9e505eb542018d304bf9e63cb60cad \ - --hash=sha256:2d08ca4bf5a9cdb6fd81b86b51bd5cfb10527d3a1199888f2b02cc6c6a3d5f80 \ - --hash=sha256:2d875224bac1a05a20acade268a7a0e3498e7f8cf890acb7c2b23bacd2bd3659 \ - --hash=sha256:2e99440ef446c7833690c1754e23b0188d440f3cb89812b0764c697a7880cb33 \ - --hash=sha256:2fa79b9a48ae59744316c71a34cb9f76f30c93e117338522c5135cb7eac10351 \ - --hash=sha256:32029441af953e980b9940f042b337fe4ad9a97fa8b17a4ed878ec67b5b89ef4 \ - --hash=sha256:33221e49be79d7bfe6d12bc52ef9458b256ab74fa8ac5660acfa3065de1c7870 \ - --hash=sha256:34c78052277053bcb09810ac8e9ba4029f9e3e277833fc18ec1c928a6246ac91 \ - --hash=sha256:35381b575f70a08d4241e73c0cde4672bb9b2a9aae8767ca6b7fb61305a052c8 \ - --hash=sha256:35fe35d20baa661cef4a12ee7bc42eaf2376d43ef9816ab29dd47ca957f7a73a \ - --hash=sha256:39f532fa0d9026de4b9315a175998646112776baad54d55348435c11b05d034e \ - --hash=sha256:3c4f631dc8d00e470cde3ec0a35d4b58a817bea592411956f1cf4099dbb306b3 \ - --hash=sha256:3d78d93846eddc990551c1c31914d90d71957ca3af468969826f7ed0d7d1d51c \ - --hash=sha256:411aa0fb234f42a1444dea1e177890ddfc577b36ffdfafec25cb0d2c818e1af8 \ - --hash=sha256:41665c7ea01d3ae497a0c61fd22d3424230d18782a5fceb79f04db9ccb2042c9 \ - --hash=sha256:4170556762c6fd2913d99dfdd8dbb4c927584899df7facb1034627e355263c45 \ - --hash=sha256:43f270a5f21e12db08c73d83849a877c02ab7b7d0fb6e2718b5101d9c810212f \ - --hash=sha256:443fa28652682d2a3ec3eca49af1b87cb2523561257683e62e03a98967b22138 \ - --hash=sha256:45cc1ccb12ced6d242335159e8221733c8696da5ad0b6ceef6debb0f5ce89110 \ - --hash=sha256:46ae05aa1aecd47d4d7de0f05ce2d1d2eb418f8de614964d754cc3b9a409c400 \ - --hash=sha256:49fb5b4960ebe1bc557b435da8687deef1302a08f030a33c5606fa6fb11b773b \ - --hash=sha256:4a9133dbaedc6f1af358127275676171d9a1bb3e61f47ee62b2ff9acb78af3d0 \ - --hash=sha256:4aa85b34c21cf97b3470793d0bf0763d32bfd1e67b83067a0a3ba8dc3474dacf \ - --hash=sha256:4d5f2492cb2192aab2d42e57f2caa2159b4fd20fde06f859ec13354f524b176c \ - --hash=sha256:4e7b5d23ef144b32645d97bd99f382c4394c443395f45fe1576786838d26e308 \ - --hash=sha256:4f7431e71072962d3ed1768038200aa4f1e76d9c2d7bedd554d57a55faaf6644 \ - --hash=sha256:508775efa11d26aec186d10154359f52276e2baf9ca201b52356330883276dfd \ - --hash=sha256:5142798871a248b422f353e0684d51dfa119de3403a6fa0904b7cb806d9790ce \ - --hash=sha256:52a2ca6f401fb49a8d5d9e0e48ade5f58f1bdda33132907b0e4e3b702628fa4e \ - --hash=sha256:53209dffd91b0aefed9ea630fa6ad5c2944d6210ba3a8a3a8664dc1575289b14 \ - --hash=sha256:534ed583fd4323de45d196535a444cebd1af2105e6e4e0cdcbf8cea266a8747a \ - --hash=sha256:54fcd24944e6d127d198c28dc363967b7131ff0c97833962c2ef2048018de02d \ - --hash=sha256:55494475206113dbaf731dc8c5ffd5e1094175b753b7bbaad09fe3d7df61165c \ - --hash=sha256:5670a762c7ae388bf8aa04199fd436180a4d28318dca9c1eae7b38604676dbfd \ - --hash=sha256:56a76556ab04ab54a675acce2cac8b4e74ea2cec558a7c9ed37e0367b04b5d50 \ - --hash=sha256:56d43c311f3519fb1057b6114dc24e9060bc3fb6aedf4aa11160484154b66b63 \ - --hash=sha256:5868a02c4f6d698ddd19b4c9742748c03dec045289915806845189d96bc3d314 \ - --hash=sha256:58e5d1e935a90878a935c7d2737a25c71cf4f1c13b6e6bef454ab722c4f338bb \ - --hash=sha256:5ad409b7ea102339041281117cf2dadfe71edbb653dc48037cc55d516fb7f448 \ - --hash=sha256:5af7070d80d278832cbca9c3c3dd44b30a26dcfa3ae1e0f6f19ff34edc27b689 \ - --hash=sha256:5d0c871efc696ef5eb8514df64b86ab28736db753ebb42a2101f3b562f4849c0 \ - --hash=sha256:5e0c0ed37472c4729f3001066510a656486f08f149dda1ebfd3dfe4450c9756e \ - --hash=sha256:5e1c174b366565e101f903f9d1cc96d5508f805759dd37d012743f1c73ec0d7b \ - --hash=sha256:62059d739af7fc035ed6e63c5390118a60a5e9aa5a5541cd0486a83e741842c7 \ - --hash=sha256:623d4b44f64efdf235b1e8dce266057073d93cde6197e7d7684f20c297f67f30 \ - --hash=sha256:628efe13630ac3397e7d1278b6be6f22f37a86d638a324334b4cd43c1daf9928 \ - --hash=sha256:6342f79d6f50e680c8aa59e0615d745e867e51ffd59c77093be60b7c433067af \ - --hash=sha256:63b87c93e28957e6dc2bb92d31727a863c9bf4a4daa080f0995fdcb0242c9166 \ - --hash=sha256:65e937576141ac50fac32fa9736dbb3e0e4a7769f91be004011a09216c6ab05f \ - --hash=sha256:66027e401c70d0cf55021b91b8a7ec2cd5810e20602f945b4fe6ae8b37ed232b \ - --hash=sha256:6644eecd99c4e8cdf72e646e17b391bb70b054f44becde37dcdc48cbec7e1527 \ - --hash=sha256:6705af9d1005127302c04d9f1533ba56e289336c465923ef878430e99295a0b9 \ - --hash=sha256:6a8248b1ba8861e48e4c7ced3895a1264d2a3b617de72ec94172ce6e64bf16c7 \ - --hash=sha256:6b14914e67044724c31a9360c7b89e9d070117c630746eefdc1620d967545acd \ - --hash=sha256:6b7b540bb321f7f0a7b8f5d107f941bb361e3a154c0b594dae0394997b91b743 \ - --hash=sha256:71250e010eede33c1d638e2c7a98288520d67f782fd5eceecaeca3129233a09a \ - --hash=sha256:722ab747ff5dc28070b1a8c020dba6466f13f2ab56bc251ad301fefa2454aa1e \ - --hash=sha256:724224d4c457207271239114bf43afafc3eca4a1b4661ccdd428d11869a03bbd \ - --hash=sha256:729989759feb81ff6493879a0d60a0bd7d2eee41001bbec0381a080318439382 \ - --hash=sha256:740bba4223fa5216d4d6c5dcd29e697f37229b8a61b69e1a50527b0ea993ab2c \ - --hash=sha256:7bdc658c9474864893e0b6b94f1390bf6a912952df1870dd93bb7d1a902b5628 \ - --hash=sha256:7d53fc4b90ef6e11de639dc18744d9be8cbd70ccb5ce80027aaffe7b1ab0215a \ - --hash=sha256:7fa69212ea60ea24e2d8aff23fe102b1f87f1b27a320f92d2e7b3468997a496a \ - --hash=sha256:80326bc571164bbbb6bead2e35d0c943b23b24fde629032197c58151529d24d8 \ - --hash=sha256:80578e91764bfca297ddca322c00a3136d993b829e9839e7fa378a19c1fcc25d \ - --hash=sha256:80a6e37e235780f189558ab804f3cbc01c18f5c578995739b49613d544d34ebf \ - --hash=sha256:8135a549e27e9941a70e2d2156676ef2ffeff4b06e9d564dbaaae963482afdef \ - --hash=sha256:82e60c00cfb8493a5ba27cdf9b3695fdd153f666f73e11f18968f78ead1d8705 \ - --hash=sha256:856bbb24f2ee6436af7609267e73309e4bb0a0f9982f79f1c2e01019279c6671 \ - --hash=sha256:85d6daa3a7ca404570ae130babbeb347ef9d3faca5300fb13cfa4bb48c3d30d1 \ - --hash=sha256:8879b6a4040ee925c638548238ab7fe612cf5fa8752fe847346da0da8be81c3f \ - --hash=sha256:8b3bede7d583bc9f47d8693a29984882e59077db1a5238ad977fc70ae8169741 \ - --hash=sha256:8dbb3862aa649de95660b6337d97101977f160b5b354d07b82fe309874d2c8bc \ - --hash=sha256:8f244dfee05ea29b8064701880865eb1684f3a704a5665bd10b64694444dbbd4 \ - --hash=sha256:8f7469408740cf06a79b01605f2ae0b2acc8839c03fcbbf05da49baee12d3302 \ - --hash=sha256:90cc9973c51811918247a78d9ab2f54871296e4e37e69a98aba4d3c9acedbf62 \ - --hash=sha256:90d8c4fa5ba293cf92de351fbc292cdaadb1a1d58d031a1a97ff9b8fb6250ec7 \ - --hash=sha256:94c02b0702656f083c098bdbd93bce22a7aa7a2b34ba56d2472efb67dab7cf58 \ - --hash=sha256:9662a9fe457820c60365b3209e03cbd8e4a817195d55de65a80fa680b4bea136 \ - --hash=sha256:997617dd7856c5dad742bf6893b872319156dd43f47587541a59e4a852d52bbc \ - --hash=sha256:99c5637b74e2805ee4de9e2a43c8b45f678e50abb1c2da9880f0372f6498ba2f \ - --hash=sha256:9a215749c9a88ef2842e880215193e56ae60b856773c99702b078236bc6d4adb \ - --hash=sha256:9bde5d82d7ac4859e0cb3a35e148f537a8d4e56aa43f42582b5f263982f095ae \ - --hash=sha256:a004c1fe5a72c496f28c6c1d8c2dc5cfe3278d99478e18f8b838c70a912b366f \ - --hash=sha256:a0e2e7c2c1a4f25db7b6596738d810c10732e77f852247300a1bc49e364d6dc9 \ - --hash=sha256:a22c4956a4d858200dba83f0691bb692d77b0eeddfca80157820d20b85dea4f1 \ - --hash=sha256:a7f391838e8adcf1f2fe71561d9e5a9f74ea7ffa5052eb2db329aac677383136 \ - --hash=sha256:a9eacb382f400bb5865e7e6d24bbe709a84510291e707f169dd4a24ee4a5ac6a \ - --hash=sha256:ace8dd1efa5f44c482d091aa38322e20e3ffe5adcdba6b67ad9f4e7da58f3f68 \ - --hash=sha256:ade76f8c84ade325bbed96806357fffd353fcc29a5166d6dc6d39381f6d9f76c \ - --hash=sha256:af37a1e6b6bd506b597d5fb4cf111fb527d1b2223608a5d441d4738827cfd166 \ - --hash=sha256:af7c474f0d8f4d2a196370e85033873c7b063a0b5da9a4f143aa023e3bd25407 \ - --hash=sha256:b225c656efa2dedecc81651d2290170af25887b7de4f38c5efc7c5ae2b9442ab \ - --hash=sha256:b459d29c8b82a420a98fa0ce8e9cf787ce0062d98cc90604ef9574fb3cc26ff8 \ - --hash=sha256:bc6f87d03e3691154673aaa8f08991723d6e3394c83ef2875c7a9049eac9595c \ - --hash=sha256:bc8fd230ef013951900c1fda100eebe4a4b1b407dde2a44186995042dda2c628 \ - --hash=sha256:bd645b5d2f810a181c44f4a71135e7c365b44f4be9ed3d1892635be13a857553 \ - --hash=sha256:be7be57097a65a8fab3d0f0a10d12931deb626a7d8082eb732b1ae41f3a202e3 \ - --hash=sha256:c15a165673bb3fb57facbf040edf5c0556d20ccd2e0ccc5f23ebb69fb715fdc1 \ - --hash=sha256:c4b3f2c7d84f303da873b634d0beef83b5e1137294c3de4e23102841dac0cf4a \ - --hash=sha256:c534c9524a2acd0b97b7022074bec3784ac273c683cf9535087e437672ea8531 \ - --hash=sha256:c95d25b5382ee0e6191ecafdeabefdc1f7bff8dfee29239b9b79e760ef3b04bd \ - --hash=sha256:c9d06edfd974e918b40e0662a7e76dac13125529ee5ec9fbe42dc0d6faa209c3 \ - --hash=sha256:cdb8e4b0f6a41b857c74c03116e8ffbef59ef8e533e934b532843178990d01d5 \ - --hash=sha256:ce39283ef08b0f28478e920d4d89a1fc169176e568e8a6a821332673145522eb \ - --hash=sha256:cfef7f7aa434a1e632e0ebda14b39f4f1d7975e0fce5c77ea7b43156fabf9bd8 \ - --hash=sha256:d2605a3b2af8d66f8b75312d223642c065ec773446c04566d9030ad5e2db13c9 \ - --hash=sha256:d338813a440bf6d2f0df55140d3f3080284410b1ebfe9df50e3847bcdb1a17bb \ - --hash=sha256:d355b22270be8062360b9df2ee0b10277590789a1059257916fb9e256a0cff80 \ - --hash=sha256:d68da281ac208b6236ffa8beef23fe7c7c956d8bf09b421d447232fd94441b35 \ - --hash=sha256:dafbb58a149d1ebd5767b3f064f911c5ee0897608005e51c3904b0ada2a8d99c \ - --hash=sha256:dc2b495a92b3971966bf102e73589f950db3ec1ca6713265355ce7771d97b199 \ - --hash=sha256:dd0f3037d501178a761fd2814370aca547f79e99a200a61a1f6e7b79ec3d78b9 \ - --hash=sha256:de2c12555bf05546f3ee75733f2c81de70aab7f530e86d0d2162b0ddcb3c39ae \ - --hash=sha256:e164c4a6868d11988c7f2386e3278ccff21c665cd7d08279b62709dd37a3167c \ - --hash=sha256:ed59cb4ee258674a6ed9b8515e42079db02cb3be94f133fdd3f200183a40c49d \ - --hash=sha256:ef07e0b6b879f699a16fc7a513b974246fdeaef44908a021130cf4afe6a1c89c \ - --hash=sha256:ef2950896673a9809e8e5ab857525924fbcacfc98b858b0509ccf50d341f80f4 \ - --hash=sha256:f09f145b89ea292cf6c25fd7c8c0d90207fd6a9e54c7828499669996a1e5bdcc \ - --hash=sha256:f27e7e8a85512b6a4b490d5756edde8e6772883a5800e1476172e44eda3f7d7c \ - --hash=sha256:f300f28dff74ff4148c21e6971e4de6ca0c34e0086a3d95c017c5174ebf85ca5 \ - --hash=sha256:f3ed747b7651668131029c720fbc32c7ee2d129bdad200e6c8f4037b20f7913a \ - --hash=sha256:f87f02afe05c1c246012e3de100a77cfbabd17db709fe93cf074271ab239b312 \ - --hash=sha256:fa87ea2ad64e59fe5622c1dc5716106b6d6e7e4b26b848cb5f5710a75c292ef5 \ - --hash=sha256:fc23e72bd7f8a8e540fed2244524d0a9aeb6ae3325f3d56f04a7a3debe5b04c2 \ - --hash=sha256:fdd00937f15f5f93c8e53d8060a8c4c010431ab595a9b5b50ddd569a04603944 \ - --hash=sha256:fe7a168d805c8a7bf1c2de55b7ab2bc958586bfd88d0e87235a92cac66b590a7 +websockets==17.4 \ + --hash=sha256:00ad98e4bb0df7a6711c645eec16d5a9fdf99f0692bd4f5b6cb85a68677ba8df \ + --hash=sha256:0132e3cbd3f5cfc1ec77588961b302d72a02e2603b82c2c23fd5fb59dc4fa206 \ + --hash=sha256:023dc13bdc42a55e152acc81c39c90ec6cc4159ed17d23c6d73460fa2a73ab16 \ + --hash=sha256:02f380df26de14999ecace203fe1396ac14d1c6132afc7924eb6f8c8b646c69d \ + --hash=sha256:03ddf4eb76ced8725dcbb000ff60b0fb8d7330ed011f6dc725a1925c5fe02051 \ + --hash=sha256:043945a43873c7c11a3f75238d475c95f902e967de329eb8763704546cfaa6b6 \ + --hash=sha256:046ed16d3d83d64594e4a40655fef061ccf36059d8c30a01feca8a9481fe13b8 \ + --hash=sha256:06ea46717553cdd10f2682126cf7a66e91c8ed17b6969f4a95e94b4e01d06188 \ + --hash=sha256:09d3939b70df0c8ad2da145ebd50978bccaee1d565e838b0e7921a34d38571eb \ + --hash=sha256:0a57b99f2e28922b5a2434317580fd23fdf118caff4c5b752f5452836426205a \ + --hash=sha256:0c22a9c6b2349204f56123dd0ca7779e989d68e336d0dc02ad823485d44925de \ + --hash=sha256:0dcce83837328e6795225ead920d3aa8d867cc9d6922aa558b0e9111599fdbb9 \ + --hash=sha256:0e51546a3bedadd80dfa5c2efaed840ac51b8632d8db7f9f502175f5349382a9 \ + --hash=sha256:156221ad7638b5abccefd6f798916bf3f8ffd6ac51480eb4ba5b05df914ccbf3 \ + --hash=sha256:16550e24ac22c27e0a728100d8de196b5f054bab6748ef9f0b168fb8b3c32e35 \ + --hash=sha256:18d3710becc612ec6f0229425bd6495133dfcbf30f7b6569585acc8ed2f1b452 \ + --hash=sha256:1962f3bfde085af18878f1bb1aafb300c9a6ac33da886d0b0d97e33c9ce7a222 \ + --hash=sha256:203e42eb77e06f85778a908b34c5ad032f34e98a68ef33920d6685c1e65a9420 \ + --hash=sha256:2103982762460a764a7ef0021443bb471f46d45ecc6858276e4fe699ebac6b94 \ + --hash=sha256:25eb661d8ba7e2b85ebb4e7a1bb9f6a3280910ed10b84330a7abfb5ed32d2764 \ + --hash=sha256:29b615c2d5dd883f89c107a782681b73a7aa8b2bd894130310e45eb09b143913 \ + --hash=sha256:2a9cb5bbfc33b4ebd3c8d3714d6e09805437f60a14977774e4bd11b5ace46c23 \ + --hash=sha256:2b23dacf7a8a88afe92f77bcdbf4cf043a84692bac2f2da02f4011c687e19b48 \ + --hash=sha256:2fd562372d4afaf18d1c7f1c68e39bd32818bb16d5f57f778a4a4ed88eedcaae \ + --hash=sha256:32110d954d9d0e51f36edb0396673bf4d35193a0a80ba7013bf7f7d58a33ca07 \ + --hash=sha256:332b88022d0acd47c44158bcd43f47f14eaddcd979247f242b1a33291c45cdd1 \ + --hash=sha256:361f9a21a33191d5287e7a75f508fc3b9bf47aaffb0241198d6b7be3384f294e \ + --hash=sha256:37ed7782972545e67d97ef9a172e9499d25459b1e32cb99d1c2a30c572f2441b \ + --hash=sha256:3acc45494c08a4d27375a2c28474537f68910219f42624a08011221b04623d04 \ + --hash=sha256:3b76837cd051d8875913f3934b27205f7a592f78b8a4f4a1f3abc36a2420c5bc \ + --hash=sha256:3b78b39377630a70aa2198e5f03be416e690364a046b1d8af80a9263733083d0 \ + --hash=sha256:3d27153a0413696767b98b5d96173a2a7e27cbfca02000a6069cea7b977765a7 \ + --hash=sha256:3d512e3a6c528c42ce220ed9c3caff83c6fdcd98a1b98249f08ccfc66ad111b1 \ + --hash=sha256:4073ab7cefe5a2e7dc368c1f01eb27716dc8ea8f1c69e9b7725ebce05aa53cf7 \ + --hash=sha256:42d7a6e818214336cf7af9a3aae5c6923f94f09fa07ac5aab98268ba2a98a6d5 \ + --hash=sha256:4441d655a53185ef1b01b1d42ad9b3a96b905110a9219043e3fdaec855ba3b6e \ + --hash=sha256:47beedf07a706140b50786457e082497ee6b5970c41e49bcd1572ee3f4c73f80 \ + --hash=sha256:48c83103f82fce36191a9e3a8a80731d4340e2da544eae8c5aaaf6a45280e7d0 \ + --hash=sha256:4b79ea31cf277c72d2307c6cb8bd32a2c151959e80a2f24fa85df620c5f2860c \ + --hash=sha256:4c792e6cce24d08f92199d907785c9e64f74c357deeed3dbaf7d699ed5930b64 \ + --hash=sha256:4d2636a5b61c69e32db0e74b57073dd4a66f1ad195e9398c5f01bba41090deea \ + --hash=sha256:4fb2db1f649b6464711846b5bb9c1611bd906ce939d9460cbd1fa419552cd6c3 \ + --hash=sha256:506623128d7a55b726a5a46e4c9a8eae4c9dd0f04ff60b041fe7df85b9d034fe \ + --hash=sha256:51adfc30f467077293a6695d8149bcef501c0728386f3a491054f835dadb60b2 \ + --hash=sha256:52c31d123f5adf14dec694c2a4036bf42c523df7cefc641d31cd05653de56e49 \ + --hash=sha256:5830f94a7190a33e55d06d89242ea951ee2009c3a27fc31538f3ac9386b27edd \ + --hash=sha256:5cec3e7fbc911ff4fcf43b24aaf1b96906381ad36030f42897def3ff4b78729e \ + --hash=sha256:5da7f7f1ecf866186f195f7ecc374c915e1838496b86f2f3b41da27c798a25c3 \ + --hash=sha256:5ec906bb21b35e66cfdcc2ea0133f249f52b7d89e3e45ec8d2bd2d84a0255d7d \ + --hash=sha256:5f772007dfba8941873dbd276b6a6845428cc740888b5c6c37bf483b1ea34b42 \ + --hash=sha256:648adde345cfd13335342e23cfe94871c7f7f55a43fb254f3b95acb071072968 \ + --hash=sha256:6531540aa63e6864cb0615a3a5da70ae03191334507852a5385320bb7916df3f \ + --hash=sha256:6597a1f7413189b4bc58437babcdf39b79da416e5c477ae98323a88ecc108188 \ + --hash=sha256:6686d29eaafddeeab9e48af92103dc0d26161035faa4be083b8b488f1ab47219 \ + --hash=sha256:679eef0123861ca381dd8fd9bdc1920e87636ac55eae6cff875c53dcb05fbcf8 \ + --hash=sha256:6afc32a7a1a40b071070419537df3559d26907086556769683fa97b7b6e90fd5 \ + --hash=sha256:6b061ea7ca94966af7cd7a87cb189ff15972c103244611a2b1e8408c04f1dbe6 \ + --hash=sha256:6cf4c002268da5c11cae9083aec71ac943bce9d95e0f83c8bf9a95a3ce421fbb \ + --hash=sha256:6e6558efbb256b532c9f06a21ba42bcac729718e365c505819f181f25e5c0eca \ + --hash=sha256:6fd8cac4907fec8302726d49e6969acfffaf720bc39b1cee23f473b81f4dce01 \ + --hash=sha256:70293c25d79d1f084fdd665894481547575e429d2a75e8d72caec2f6b6b07af8 \ + --hash=sha256:71f76bb8e59be847330b8a8d3753f85397677e128f9f9692c215f8cf721cefc7 \ + --hash=sha256:725be1aff55f1c873a79cea0f612df9dff21a9d2c2e05783665b824b8aa7c24e \ + --hash=sha256:72d0a731eccf482d5661c6711abfcd5921f3dbfbbb0789061e1918ef4bc89c70 \ + --hash=sha256:7390e060f830b3d132f484b21ac2783a2d227b682ef62bb8146e1c1b4ca0e45a \ + --hash=sha256:73b00645316121105274fd266ed54a20e6514650126d399cdea39876ab1e7b04 \ + --hash=sha256:74e46409666c358b93fce0d8c6f4cb107d8dd7cba1302a38d49adf6fa8193c7e \ + --hash=sha256:75b32cf33ea40f945f226f97bc1d43151e1a5adff21f420b5e63c468b1a28114 \ + --hash=sha256:769e4f7b090e10cae4c082e3e39b9458ec2444505df84953c16c0468b16539c4 \ + --hash=sha256:7849a096d6fdba3f2bdf849eb89f2d233a24400c92faa3efb25fa8dc615b706c \ + --hash=sha256:799c5a23126c9589cb10b6c6b54f9087246940dad20cd74bf3f06fc9ae3bbab5 \ + --hash=sha256:7a7e9321822ca59ba56fa8744c3e3f01ef74d547aafe0d383d000d7a4d11eb79 \ + --hash=sha256:7b23db4b469d9e051ac74d96db9bf61e27c9054de64f8263fefa32bf72867ba1 \ + --hash=sha256:7b4ee34acefa9ba1333f02b817982f1e4127f6f9b975017a707c4e738d7f9fb9 \ + --hash=sha256:829bcaa90be8737c132d9515d6a466e8528e3b3d050d3f3466f36ea94d3beb48 \ + --hash=sha256:8588edf317fbe4f16a949bc5a6ccdd22b6e6fd5b4745d128bc40bccf31024ce5 \ + --hash=sha256:868744c68e9342768888c7326604da2228c19bb6946aa46b5f9e1ac32ea8d40b \ + --hash=sha256:88ba8a439b2bad97bb69c38db5bf0ec16a061ee834cf0f244089186ee803e899 \ + --hash=sha256:8b45d40f07081b377f0b837ddbfba4a9ab4a89a7b569d3c095436564e767a27b \ + --hash=sha256:8c00c5c49f80a588a2c4f99d519d2abed4ce26cdca11273041bc8b7e60af60de \ + --hash=sha256:8c51ac7b31578a7275650f580aac985940985b6d2e57247ac6d5e07f2c936cd6 \ + --hash=sha256:8d148b4b295224d3a19e7bd9f02ebe7344de254b3b2c4d0628ce42ade0bca119 \ + --hash=sha256:8f32a6cb0b101170abc2d8053af1473a2a29ef2a06d7130994ee8455a50f5dbf \ + --hash=sha256:8fd30d217a4275107667fe1d4f601e2d50c797d0727148cfbb384536d65f04d3 \ + --hash=sha256:927d602fb8fb595a69e6b1ae998a6ec19851d0fb9391759456e6425090996647 \ + --hash=sha256:94622cf24e80d03054ceb69a06a86c950e12fe993ed06b933f7895232cacbc3e \ + --hash=sha256:954d43574f72be244b65f0fa61e2f814214b024f2ec849f487aa2f553f7c6e71 \ + --hash=sha256:98f16db5e79f2605194c0f899d0532005bb12269e698bf96f62a5635bcf33010 \ + --hash=sha256:9a4e95d56994a45ac76541853796720436ffe462957d0b1b89e87ff3d3b1627e \ + --hash=sha256:9ae1429ee9e52b139167ed0169757806a27ed7307e942e8c62037a9184e9843b \ + --hash=sha256:9e282f35ece8895b9fa5af406fc6a4cb42f01e1009525cf46641de584781c83b \ + --hash=sha256:9ff89e36451a19775e7556d95ebfa4a0b24b84cc87565d6b5d0ec8ea82bf3f2c \ + --hash=sha256:a0457561e8a87a6a3b509e21c35084d7d207ced262139de3f738e9676a476bbd \ + --hash=sha256:a50bd9b44dd9b7b845802030b4d88610c01c7f95571b4cbd3a1aeec17a34c9fd \ + --hash=sha256:a703f23ca15a51251fc94f1774683f5aa511588a7ad6bbb83960cdb2a4dcd978 \ + --hash=sha256:a920e99155d75d1b1c08172da8aaa7200e7b30871fb628d1631d7891a333e30e \ + --hash=sha256:a9b0026538719f7a532998d84640ab2e91ca1a051ce4b8d924fdcb607307e921 \ + --hash=sha256:ab9e4bd60796866c578e3afa0a17c98636887827a39c2e238a61989c18fd8e7b \ + --hash=sha256:accb5e9492b9bc9c9aeec5b72ea8f2ddcbefc6143d1c626fba960ecbdc89807e \ + --hash=sha256:ad2e9e152bec6c4f7e4cdb4adcea6b708400090eec04bfe5ee5b30d368ffd721 \ + --hash=sha256:adc30e5d064c9e33049e825408fa4e952f4e8a0cf4c329dc2ded72e1bb5bae08 \ + --hash=sha256:b529ad6c81ad21db2f4e3d6e33db78ce3b9f272066b438b5d673361f9282898d \ + --hash=sha256:b6c7f91520c29420639925fd1cecceac9ca07366b50c09ea24eac1cd116b4866 \ + --hash=sha256:b7ea61af293a153f2d67430038cf0efc10140caa623f2bdf5a9f5a457ff20f57 \ + --hash=sha256:b8451f31ae24620cc9372acd2728d87922f2bb0cae3fa57aed68b152844cdf08 \ + --hash=sha256:bbb6bed49f083e2ec8ae0bb21c00c7c5171ab8e08a8b008c61a43fc6c944fdac \ + --hash=sha256:bc581579885728bb54ef15000295c20e6a64a200b1b73d5cfca71879a3eb3e1f \ + --hash=sha256:bcad49104711f88b2b7c3ba166de172857d837248205e541b044a552558fecc3 \ + --hash=sha256:bfc86dff091104eec770c29ea1831dbe191968a70aa2d8ae8eda3240d863970a \ + --hash=sha256:c00f0c45851a2c61efffd03dde8fb4d594026b5cc18196c94e15460f1225f2ed \ + --hash=sha256:c0cc2cb47face4dae64bd70a9268dbebddb3e48c1b48cf6d7b6ac1471609fd88 \ + --hash=sha256:c2db7b9e2d726f7e051bedb67abcf460b84b11437b2f9fdacda7e6931dace85b \ + --hash=sha256:c44776f82b04e623eb9e1b81fcd17ac6e9808ae1ba5cdf8edce1f7c869a95a9b \ + --hash=sha256:c564d1e6531611bf87078c28780456ecc75459dcca189629fff2fe7c020d8e67 \ + --hash=sha256:c568e72f5425a74616daeb824afe28da460cbeea036432f41c6af5380b83300d \ + --hash=sha256:c5e970d3996c597186c9dbb6e77a8048737a834ac03cb13186a54999e0e1c31e \ + --hash=sha256:c634053459278cf31fb404ddb5ecd38124e4609e8b609166da621edc2d5796db \ + --hash=sha256:c63c0d7832956d5f3bb77888b93c854569d13f806289ff75c43926d25c6e5fd2 \ + --hash=sha256:c6bd2c9781915592b209160d0f017d4c7c82ad53075e6b0c24d2a89299e7f9e9 \ + --hash=sha256:c74954409ea5789f7a60daa5f94580c00ad0b60b20751c47b35c4784877b5057 \ + --hash=sha256:c7a56140fcbf4662cb263754a754c651a16fe2196cae1fd232d18f89acd30c83 \ + --hash=sha256:c7b996c02de5e032cb63dc65482b73693b7ecaa586654126be2749ddea239c40 \ + --hash=sha256:c7f66a53e9b7ec831abb15e3d9b6cee8f52d6a24cd0a374786bf46c3ec218bc6 \ + --hash=sha256:c8052c3058c402d53602583da5ddcf1a15a63ab1efe0222f47c7be59944e6463 \ + --hash=sha256:c9f8fdb7bbefc4907c2bb3593f810f2b67a2d5329e1d38724a1aa3d763cf63eb \ + --hash=sha256:cbdf4b42f4514422051889424fd03bc269be8c5d751bf39ec29ad761590bd10d \ + --hash=sha256:cc0cd4065a65ac98f3e2742ee9f314da9d224db49b07dc17d3a20cef0d79658d \ + --hash=sha256:cebad8f4796248b91fc2168515e507e5cb6b663a32befd09f3b1e7920acdcb1a \ + --hash=sha256:cf99debb17227a50620aee16647bb33a0387f5fa5418d628666c0f05745e0d2a \ + --hash=sha256:cfbbba4597f19ad717f79fd3f2a67ae7efae647bd6a61e14554e1b5ad242a5bf \ + --hash=sha256:d0acd8191c0e5a61d1c27bfa33b6ec8431982299f5d71c02129aa4f16f182ad0 \ + --hash=sha256:d0b6a9bd86f865ebc9d847a1eb0a655a0e2b447cbcde743dda97c4298415cc0b \ + --hash=sha256:d0f943b53bb1d7389d72a5380078670e5fcc34a1328142148b72d20fd82031c7 \ + --hash=sha256:d48795c7d64ca1c42e3d61fcc6478cb15a19b6ad3f9c882bc8d8dee49d4a420f \ + --hash=sha256:d8255971f6789cf0cb365e3e22b9b166eba978548a26241eaa5b7af0440d885a \ + --hash=sha256:ddab54dc0ec46ed09ef9ac131252cb5740ed21fbe6fc145f3c6fed0b575a8f15 \ + --hash=sha256:dde9cd67f18803949ebf96de8aba12b8ec5ee3115e34c75a2db85a3b0a6c20d5 \ + --hash=sha256:de29795f23833cdfb68ba45c8a5cc0ec49778ea5deb670188f4465bba5930d25 \ + --hash=sha256:e0feb03a338f8f5e819ae23a73aabb7b46fd3b73f15f7df782dd12d99e2b02ad \ + --hash=sha256:e30550965c78223439815dbdf40c93eb8931dfbf12427110dcfcaeebef76feb6 \ + --hash=sha256:e3e80cad85ba68c07c1e8d4ccc9cf51e63bf6a73d6bf631640be947bd72b6b63 \ + --hash=sha256:e4beaef2f4796558e738e15ca0b8802e35f419a9fa374a978e41706f43c48b26 \ + --hash=sha256:e6d2a185ec4ff0478089305abd833da3a9fda8803eb77325b89ebeae5585a3b7 \ + --hash=sha256:ed7fe197b8bb66eef4f62c052e33752d456d2fdda74805469d4bd954950ab339 \ + --hash=sha256:ee48728a07566c32e097d267f4d4d01b85123c9ae748a42da02185b908cbedc4 \ + --hash=sha256:ee6c4e0085bff5f6621bf9c53452584dcb91ebd993ce841e1f4061ac12b6b556 \ + --hash=sha256:ef03fb4b3685df0ed9dc6a074a090f030345cad2c23c61818da3e08e693a9caa \ + --hash=sha256:f01a680d4b66fd15cc7513fae55c673cc8cc340d41340305b7f9a782abdbefed \ + --hash=sha256:f0494d3d97502e270282cc6a1f8648cd7335b4fe8d5c452006e5b7f9d62b8892 \ + --hash=sha256:f24bed98595b9d854da8d6debaf82754abf3e8fe63ec90505ef560566814df7e \ + --hash=sha256:f42671900e0b602d05a074c52fd3d757c2f8e5c4f73f771794dae1aa4aea5f0e \ + --hash=sha256:f5b1d315a615cec81a79e2ce3a94221dc5c23e7fd4012897085d3b5e5a5638d0 \ + --hash=sha256:f5f06f59e19aa0770aaf651ce91c196fa620b35aed49a7e6c36e7335f3f83db7 \ + --hash=sha256:fcac44d917672ad6257ad64b80b5168306f168a78cab5c0612e4c699caed18cf # via uvicorn diff --git a/backend/requirements.lock.txt b/backend/requirements.lock.txt index 7764df20a..f07d5cba4 100644 --- a/backend/requirements.lock.txt +++ b/backend/requirements.lock.txt @@ -16,9 +16,9 @@ annotated-types==0.8.0 \ --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \ --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 # via pydantic -anyio==4.15.1 \ - --hash=sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101 \ - --hash=sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94 +anyio==4.16.0 \ + --hash=sha256:59bb03251907983d1603d27c3087ddb1e93299ea2163adcdba37899a4fb12ffb \ + --hash=sha256:c66ae634e0b8228396b801741beb3294326d4b6102f7bba4041c0dfb58c29026 # via # httpx # httpx2 @@ -828,9 +828,9 @@ sqlmodel==0.0.48 \ --hash=sha256:5582e87e845e23bb1179a7d8b11a4f5e441b4494528a41ee2fe1d129ffe91543 \ --hash=sha256:8d389bf735b03a17508e93e888c13a30ef1ddca22dd8e57add12317401e4112e # via -r requirements.txt -starlette==1.9.0 \ - --hash=sha256:3d931de246ed870e35ce605974440816ef895e592f255d1396cfdf191d4c4318 \ - --hash=sha256:fd3f159936e882d06c1108f84688f7cda2cf85fc6a592a50edbd175e882c2ddc +starlette==1.10.0 \ + --hash=sha256:478c0ab3b4ced6e82719849c118fa406f0278a3499c1a898cd15cd31bdc38ce4 \ + --hash=sha256:b8399098d4c1e3522734f7235dc79b1fc967f96cf139c81e0fa30479e0685f9b # via fastapi truststore==0.10.5 \ --hash=sha256:30d36967ccaded5cbb38d602c433f53600036c79d502f4533a49b60a03bbefcd \ @@ -847,6 +847,7 @@ typing-extensions==4.16.0 \ # opentelemetry-api # pydantic # pydantic-core + # pydantic-settings # sqlalchemy # sqlmodel # typing-inspection @@ -959,159 +960,159 @@ watchfiles==1.3.0 \ --hash=sha256:ec3cd4bb181a7b6329134204c2277c7144ab5f62cce9c5335d6f901ee7ce8d6a \ --hash=sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3 # via uvicorn -websockets==17.3 \ - --hash=sha256:018fab26140ce221c3853454a608e4e7004f4f10641cb97819dcd5b917bb2e99 \ - --hash=sha256:07246d4f0293ef0b87eff3d7dd88c1f03dd9f678fa39f6de87b2af8504e7e0de \ - --hash=sha256:078d1d41f45ee34eed5a4f3bbe7c588caecbdd6e12dfca73e8c1b0e803773793 \ - --hash=sha256:0824e6ecee838cc28e6481062574c7fde49f9a2cedfacd36f0e91cb4b37fd6fe \ - --hash=sha256:09f007b2150eff015b2109f006e2ac2012d376f41486defa88ae78a776b00cff \ - --hash=sha256:0a0bcf66289b9d017c175562c35c5304b5cc3ed56aded994ae30b0a9654ba405 \ - --hash=sha256:0a657b51e4a5f4ac577c11e706ff9a884ea5aaf3e2666bae283a251c63e66acb \ - --hash=sha256:0c63ad657695e8f1a48624754cb6b464118ac50d4c1d477c26461c98390f0a62 \ - --hash=sha256:0d20d6ea5aabbeba2e054d33e405192ef75dc12947bbd00ec97d23b14969d325 \ - --hash=sha256:0e65260a1b1d7b81651f47343d836de70b88b02358ae3658efa6382104ec37b1 \ - --hash=sha256:0f78bb2e82fe88912c9d7b25c98e57a9b08655579af89f164fd4c0657a075d62 \ - --hash=sha256:10b24e836ee0233ca078e1244f6ba40343a22127a9a736f46a7e7b18de2cc711 \ - --hash=sha256:13cffaedde6e06183860fc92d88fe1d190abda73837fe4c14f5d4803c472f5dd \ - --hash=sha256:13eace1b315345ebac5b27821f5dcce24e5cf9976ab18f79e00a59bdd67e5d19 \ - --hash=sha256:15decc0fe293d9e3f924bd483f3d76f3d0ff26845112a470e3f8842fec2c82a1 \ - --hash=sha256:1c7dd93a1c9f89297471493674b1fc1d9467cafd45374f2172213046bdb3a329 \ - --hash=sha256:1dae5cd6c92d4440b15f20aa2987baa73c47ea14baa0fec8536a542e82e0ab33 \ - --hash=sha256:1db2604e6aedb7a2cb2f6e0575d6c9a852ccac8239703a930ac83b63309177a5 \ - --hash=sha256:2129c9a5fc05a230ce088745a790af073e582b584badc7b05db067ee326f56d7 \ - --hash=sha256:23046098ebf844753cfd211033624a8247520e5bf2194c24cb1a8c222917acff \ - --hash=sha256:23cb6f6d64e3a389bb8ba150f947f9a6c8c0aca383e814ca3c5cffab738d5646 \ - --hash=sha256:26838e2d95d7004c52e8c7b6b51676c872093a3b55a497824f899116a15a8a55 \ - --hash=sha256:2789d203babb6ceb2770aadc2394c1f99263ec37fba57a93064f9a8ad899e8ff \ - --hash=sha256:28d1935c16782a1e3650c9baa79069b016fff739e292e4cb14bbb830d6930491 \ - --hash=sha256:29b01520c39ef2f23c88b002f11e6ef4165675f6c92f5f3578fd587d69b5a5fc \ - --hash=sha256:2a377c7132b1e69be7a50d7434d7f26f69e93c70166ebb6d8f8cabf802ac4506 \ - --hash=sha256:2b06213c8e1ce93b18d0476d7a5e647436c8f23d099888c9065e930e80f4bed5 \ - --hash=sha256:2c460aeb12c2bd1c204944d9e9199595bf9e505eb542018d304bf9e63cb60cad \ - --hash=sha256:2d08ca4bf5a9cdb6fd81b86b51bd5cfb10527d3a1199888f2b02cc6c6a3d5f80 \ - --hash=sha256:2d875224bac1a05a20acade268a7a0e3498e7f8cf890acb7c2b23bacd2bd3659 \ - --hash=sha256:2e99440ef446c7833690c1754e23b0188d440f3cb89812b0764c697a7880cb33 \ - --hash=sha256:2fa79b9a48ae59744316c71a34cb9f76f30c93e117338522c5135cb7eac10351 \ - --hash=sha256:32029441af953e980b9940f042b337fe4ad9a97fa8b17a4ed878ec67b5b89ef4 \ - --hash=sha256:33221e49be79d7bfe6d12bc52ef9458b256ab74fa8ac5660acfa3065de1c7870 \ - --hash=sha256:34c78052277053bcb09810ac8e9ba4029f9e3e277833fc18ec1c928a6246ac91 \ - --hash=sha256:35381b575f70a08d4241e73c0cde4672bb9b2a9aae8767ca6b7fb61305a052c8 \ - --hash=sha256:35fe35d20baa661cef4a12ee7bc42eaf2376d43ef9816ab29dd47ca957f7a73a \ - --hash=sha256:39f532fa0d9026de4b9315a175998646112776baad54d55348435c11b05d034e \ - --hash=sha256:3c4f631dc8d00e470cde3ec0a35d4b58a817bea592411956f1cf4099dbb306b3 \ - --hash=sha256:3d78d93846eddc990551c1c31914d90d71957ca3af468969826f7ed0d7d1d51c \ - --hash=sha256:411aa0fb234f42a1444dea1e177890ddfc577b36ffdfafec25cb0d2c818e1af8 \ - --hash=sha256:41665c7ea01d3ae497a0c61fd22d3424230d18782a5fceb79f04db9ccb2042c9 \ - --hash=sha256:4170556762c6fd2913d99dfdd8dbb4c927584899df7facb1034627e355263c45 \ - --hash=sha256:43f270a5f21e12db08c73d83849a877c02ab7b7d0fb6e2718b5101d9c810212f \ - --hash=sha256:443fa28652682d2a3ec3eca49af1b87cb2523561257683e62e03a98967b22138 \ - --hash=sha256:45cc1ccb12ced6d242335159e8221733c8696da5ad0b6ceef6debb0f5ce89110 \ - --hash=sha256:46ae05aa1aecd47d4d7de0f05ce2d1d2eb418f8de614964d754cc3b9a409c400 \ - --hash=sha256:49fb5b4960ebe1bc557b435da8687deef1302a08f030a33c5606fa6fb11b773b \ - --hash=sha256:4a9133dbaedc6f1af358127275676171d9a1bb3e61f47ee62b2ff9acb78af3d0 \ - --hash=sha256:4aa85b34c21cf97b3470793d0bf0763d32bfd1e67b83067a0a3ba8dc3474dacf \ - --hash=sha256:4d5f2492cb2192aab2d42e57f2caa2159b4fd20fde06f859ec13354f524b176c \ - --hash=sha256:4e7b5d23ef144b32645d97bd99f382c4394c443395f45fe1576786838d26e308 \ - --hash=sha256:4f7431e71072962d3ed1768038200aa4f1e76d9c2d7bedd554d57a55faaf6644 \ - --hash=sha256:508775efa11d26aec186d10154359f52276e2baf9ca201b52356330883276dfd \ - --hash=sha256:5142798871a248b422f353e0684d51dfa119de3403a6fa0904b7cb806d9790ce \ - --hash=sha256:52a2ca6f401fb49a8d5d9e0e48ade5f58f1bdda33132907b0e4e3b702628fa4e \ - --hash=sha256:53209dffd91b0aefed9ea630fa6ad5c2944d6210ba3a8a3a8664dc1575289b14 \ - --hash=sha256:534ed583fd4323de45d196535a444cebd1af2105e6e4e0cdcbf8cea266a8747a \ - --hash=sha256:54fcd24944e6d127d198c28dc363967b7131ff0c97833962c2ef2048018de02d \ - --hash=sha256:55494475206113dbaf731dc8c5ffd5e1094175b753b7bbaad09fe3d7df61165c \ - --hash=sha256:5670a762c7ae388bf8aa04199fd436180a4d28318dca9c1eae7b38604676dbfd \ - --hash=sha256:56a76556ab04ab54a675acce2cac8b4e74ea2cec558a7c9ed37e0367b04b5d50 \ - --hash=sha256:56d43c311f3519fb1057b6114dc24e9060bc3fb6aedf4aa11160484154b66b63 \ - --hash=sha256:5868a02c4f6d698ddd19b4c9742748c03dec045289915806845189d96bc3d314 \ - --hash=sha256:58e5d1e935a90878a935c7d2737a25c71cf4f1c13b6e6bef454ab722c4f338bb \ - --hash=sha256:5ad409b7ea102339041281117cf2dadfe71edbb653dc48037cc55d516fb7f448 \ - --hash=sha256:5af7070d80d278832cbca9c3c3dd44b30a26dcfa3ae1e0f6f19ff34edc27b689 \ - --hash=sha256:5d0c871efc696ef5eb8514df64b86ab28736db753ebb42a2101f3b562f4849c0 \ - --hash=sha256:5e0c0ed37472c4729f3001066510a656486f08f149dda1ebfd3dfe4450c9756e \ - --hash=sha256:5e1c174b366565e101f903f9d1cc96d5508f805759dd37d012743f1c73ec0d7b \ - --hash=sha256:62059d739af7fc035ed6e63c5390118a60a5e9aa5a5541cd0486a83e741842c7 \ - --hash=sha256:623d4b44f64efdf235b1e8dce266057073d93cde6197e7d7684f20c297f67f30 \ - --hash=sha256:628efe13630ac3397e7d1278b6be6f22f37a86d638a324334b4cd43c1daf9928 \ - --hash=sha256:6342f79d6f50e680c8aa59e0615d745e867e51ffd59c77093be60b7c433067af \ - --hash=sha256:63b87c93e28957e6dc2bb92d31727a863c9bf4a4daa080f0995fdcb0242c9166 \ - --hash=sha256:65e937576141ac50fac32fa9736dbb3e0e4a7769f91be004011a09216c6ab05f \ - --hash=sha256:66027e401c70d0cf55021b91b8a7ec2cd5810e20602f945b4fe6ae8b37ed232b \ - --hash=sha256:6644eecd99c4e8cdf72e646e17b391bb70b054f44becde37dcdc48cbec7e1527 \ - --hash=sha256:6705af9d1005127302c04d9f1533ba56e289336c465923ef878430e99295a0b9 \ - --hash=sha256:6a8248b1ba8861e48e4c7ced3895a1264d2a3b617de72ec94172ce6e64bf16c7 \ - --hash=sha256:6b14914e67044724c31a9360c7b89e9d070117c630746eefdc1620d967545acd \ - --hash=sha256:6b7b540bb321f7f0a7b8f5d107f941bb361e3a154c0b594dae0394997b91b743 \ - --hash=sha256:71250e010eede33c1d638e2c7a98288520d67f782fd5eceecaeca3129233a09a \ - --hash=sha256:722ab747ff5dc28070b1a8c020dba6466f13f2ab56bc251ad301fefa2454aa1e \ - --hash=sha256:724224d4c457207271239114bf43afafc3eca4a1b4661ccdd428d11869a03bbd \ - --hash=sha256:729989759feb81ff6493879a0d60a0bd7d2eee41001bbec0381a080318439382 \ - --hash=sha256:740bba4223fa5216d4d6c5dcd29e697f37229b8a61b69e1a50527b0ea993ab2c \ - --hash=sha256:7bdc658c9474864893e0b6b94f1390bf6a912952df1870dd93bb7d1a902b5628 \ - --hash=sha256:7d53fc4b90ef6e11de639dc18744d9be8cbd70ccb5ce80027aaffe7b1ab0215a \ - --hash=sha256:7fa69212ea60ea24e2d8aff23fe102b1f87f1b27a320f92d2e7b3468997a496a \ - --hash=sha256:80326bc571164bbbb6bead2e35d0c943b23b24fde629032197c58151529d24d8 \ - --hash=sha256:80578e91764bfca297ddca322c00a3136d993b829e9839e7fa378a19c1fcc25d \ - --hash=sha256:80a6e37e235780f189558ab804f3cbc01c18f5c578995739b49613d544d34ebf \ - --hash=sha256:8135a549e27e9941a70e2d2156676ef2ffeff4b06e9d564dbaaae963482afdef \ - --hash=sha256:82e60c00cfb8493a5ba27cdf9b3695fdd153f666f73e11f18968f78ead1d8705 \ - --hash=sha256:856bbb24f2ee6436af7609267e73309e4bb0a0f9982f79f1c2e01019279c6671 \ - --hash=sha256:85d6daa3a7ca404570ae130babbeb347ef9d3faca5300fb13cfa4bb48c3d30d1 \ - --hash=sha256:8879b6a4040ee925c638548238ab7fe612cf5fa8752fe847346da0da8be81c3f \ - --hash=sha256:8b3bede7d583bc9f47d8693a29984882e59077db1a5238ad977fc70ae8169741 \ - --hash=sha256:8dbb3862aa649de95660b6337d97101977f160b5b354d07b82fe309874d2c8bc \ - --hash=sha256:8f244dfee05ea29b8064701880865eb1684f3a704a5665bd10b64694444dbbd4 \ - --hash=sha256:8f7469408740cf06a79b01605f2ae0b2acc8839c03fcbbf05da49baee12d3302 \ - --hash=sha256:90cc9973c51811918247a78d9ab2f54871296e4e37e69a98aba4d3c9acedbf62 \ - --hash=sha256:90d8c4fa5ba293cf92de351fbc292cdaadb1a1d58d031a1a97ff9b8fb6250ec7 \ - --hash=sha256:94c02b0702656f083c098bdbd93bce22a7aa7a2b34ba56d2472efb67dab7cf58 \ - --hash=sha256:9662a9fe457820c60365b3209e03cbd8e4a817195d55de65a80fa680b4bea136 \ - --hash=sha256:997617dd7856c5dad742bf6893b872319156dd43f47587541a59e4a852d52bbc \ - --hash=sha256:99c5637b74e2805ee4de9e2a43c8b45f678e50abb1c2da9880f0372f6498ba2f \ - --hash=sha256:9a215749c9a88ef2842e880215193e56ae60b856773c99702b078236bc6d4adb \ - --hash=sha256:9bde5d82d7ac4859e0cb3a35e148f537a8d4e56aa43f42582b5f263982f095ae \ - --hash=sha256:a004c1fe5a72c496f28c6c1d8c2dc5cfe3278d99478e18f8b838c70a912b366f \ - --hash=sha256:a0e2e7c2c1a4f25db7b6596738d810c10732e77f852247300a1bc49e364d6dc9 \ - --hash=sha256:a22c4956a4d858200dba83f0691bb692d77b0eeddfca80157820d20b85dea4f1 \ - --hash=sha256:a7f391838e8adcf1f2fe71561d9e5a9f74ea7ffa5052eb2db329aac677383136 \ - --hash=sha256:a9eacb382f400bb5865e7e6d24bbe709a84510291e707f169dd4a24ee4a5ac6a \ - --hash=sha256:ace8dd1efa5f44c482d091aa38322e20e3ffe5adcdba6b67ad9f4e7da58f3f68 \ - --hash=sha256:ade76f8c84ade325bbed96806357fffd353fcc29a5166d6dc6d39381f6d9f76c \ - --hash=sha256:af37a1e6b6bd506b597d5fb4cf111fb527d1b2223608a5d441d4738827cfd166 \ - --hash=sha256:af7c474f0d8f4d2a196370e85033873c7b063a0b5da9a4f143aa023e3bd25407 \ - --hash=sha256:b225c656efa2dedecc81651d2290170af25887b7de4f38c5efc7c5ae2b9442ab \ - --hash=sha256:b459d29c8b82a420a98fa0ce8e9cf787ce0062d98cc90604ef9574fb3cc26ff8 \ - --hash=sha256:bc6f87d03e3691154673aaa8f08991723d6e3394c83ef2875c7a9049eac9595c \ - --hash=sha256:bc8fd230ef013951900c1fda100eebe4a4b1b407dde2a44186995042dda2c628 \ - --hash=sha256:bd645b5d2f810a181c44f4a71135e7c365b44f4be9ed3d1892635be13a857553 \ - --hash=sha256:be7be57097a65a8fab3d0f0a10d12931deb626a7d8082eb732b1ae41f3a202e3 \ - --hash=sha256:c15a165673bb3fb57facbf040edf5c0556d20ccd2e0ccc5f23ebb69fb715fdc1 \ - --hash=sha256:c4b3f2c7d84f303da873b634d0beef83b5e1137294c3de4e23102841dac0cf4a \ - --hash=sha256:c534c9524a2acd0b97b7022074bec3784ac273c683cf9535087e437672ea8531 \ - --hash=sha256:c95d25b5382ee0e6191ecafdeabefdc1f7bff8dfee29239b9b79e760ef3b04bd \ - --hash=sha256:c9d06edfd974e918b40e0662a7e76dac13125529ee5ec9fbe42dc0d6faa209c3 \ - --hash=sha256:cdb8e4b0f6a41b857c74c03116e8ffbef59ef8e533e934b532843178990d01d5 \ - --hash=sha256:ce39283ef08b0f28478e920d4d89a1fc169176e568e8a6a821332673145522eb \ - --hash=sha256:cfef7f7aa434a1e632e0ebda14b39f4f1d7975e0fce5c77ea7b43156fabf9bd8 \ - --hash=sha256:d2605a3b2af8d66f8b75312d223642c065ec773446c04566d9030ad5e2db13c9 \ - --hash=sha256:d338813a440bf6d2f0df55140d3f3080284410b1ebfe9df50e3847bcdb1a17bb \ - --hash=sha256:d355b22270be8062360b9df2ee0b10277590789a1059257916fb9e256a0cff80 \ - --hash=sha256:d68da281ac208b6236ffa8beef23fe7c7c956d8bf09b421d447232fd94441b35 \ - --hash=sha256:dafbb58a149d1ebd5767b3f064f911c5ee0897608005e51c3904b0ada2a8d99c \ - --hash=sha256:dc2b495a92b3971966bf102e73589f950db3ec1ca6713265355ce7771d97b199 \ - --hash=sha256:dd0f3037d501178a761fd2814370aca547f79e99a200a61a1f6e7b79ec3d78b9 \ - --hash=sha256:de2c12555bf05546f3ee75733f2c81de70aab7f530e86d0d2162b0ddcb3c39ae \ - --hash=sha256:e164c4a6868d11988c7f2386e3278ccff21c665cd7d08279b62709dd37a3167c \ - --hash=sha256:ed59cb4ee258674a6ed9b8515e42079db02cb3be94f133fdd3f200183a40c49d \ - --hash=sha256:ef07e0b6b879f699a16fc7a513b974246fdeaef44908a021130cf4afe6a1c89c \ - --hash=sha256:ef2950896673a9809e8e5ab857525924fbcacfc98b858b0509ccf50d341f80f4 \ - --hash=sha256:f09f145b89ea292cf6c25fd7c8c0d90207fd6a9e54c7828499669996a1e5bdcc \ - --hash=sha256:f27e7e8a85512b6a4b490d5756edde8e6772883a5800e1476172e44eda3f7d7c \ - --hash=sha256:f300f28dff74ff4148c21e6971e4de6ca0c34e0086a3d95c017c5174ebf85ca5 \ - --hash=sha256:f3ed747b7651668131029c720fbc32c7ee2d129bdad200e6c8f4037b20f7913a \ - --hash=sha256:f87f02afe05c1c246012e3de100a77cfbabd17db709fe93cf074271ab239b312 \ - --hash=sha256:fa87ea2ad64e59fe5622c1dc5716106b6d6e7e4b26b848cb5f5710a75c292ef5 \ - --hash=sha256:fc23e72bd7f8a8e540fed2244524d0a9aeb6ae3325f3d56f04a7a3debe5b04c2 \ - --hash=sha256:fdd00937f15f5f93c8e53d8060a8c4c010431ab595a9b5b50ddd569a04603944 \ - --hash=sha256:fe7a168d805c8a7bf1c2de55b7ab2bc958586bfd88d0e87235a92cac66b590a7 +websockets==17.4 \ + --hash=sha256:00ad98e4bb0df7a6711c645eec16d5a9fdf99f0692bd4f5b6cb85a68677ba8df \ + --hash=sha256:0132e3cbd3f5cfc1ec77588961b302d72a02e2603b82c2c23fd5fb59dc4fa206 \ + --hash=sha256:023dc13bdc42a55e152acc81c39c90ec6cc4159ed17d23c6d73460fa2a73ab16 \ + --hash=sha256:02f380df26de14999ecace203fe1396ac14d1c6132afc7924eb6f8c8b646c69d \ + --hash=sha256:03ddf4eb76ced8725dcbb000ff60b0fb8d7330ed011f6dc725a1925c5fe02051 \ + --hash=sha256:043945a43873c7c11a3f75238d475c95f902e967de329eb8763704546cfaa6b6 \ + --hash=sha256:046ed16d3d83d64594e4a40655fef061ccf36059d8c30a01feca8a9481fe13b8 \ + --hash=sha256:06ea46717553cdd10f2682126cf7a66e91c8ed17b6969f4a95e94b4e01d06188 \ + --hash=sha256:09d3939b70df0c8ad2da145ebd50978bccaee1d565e838b0e7921a34d38571eb \ + --hash=sha256:0a57b99f2e28922b5a2434317580fd23fdf118caff4c5b752f5452836426205a \ + --hash=sha256:0c22a9c6b2349204f56123dd0ca7779e989d68e336d0dc02ad823485d44925de \ + --hash=sha256:0dcce83837328e6795225ead920d3aa8d867cc9d6922aa558b0e9111599fdbb9 \ + --hash=sha256:0e51546a3bedadd80dfa5c2efaed840ac51b8632d8db7f9f502175f5349382a9 \ + --hash=sha256:156221ad7638b5abccefd6f798916bf3f8ffd6ac51480eb4ba5b05df914ccbf3 \ + --hash=sha256:16550e24ac22c27e0a728100d8de196b5f054bab6748ef9f0b168fb8b3c32e35 \ + --hash=sha256:18d3710becc612ec6f0229425bd6495133dfcbf30f7b6569585acc8ed2f1b452 \ + --hash=sha256:1962f3bfde085af18878f1bb1aafb300c9a6ac33da886d0b0d97e33c9ce7a222 \ + --hash=sha256:203e42eb77e06f85778a908b34c5ad032f34e98a68ef33920d6685c1e65a9420 \ + --hash=sha256:2103982762460a764a7ef0021443bb471f46d45ecc6858276e4fe699ebac6b94 \ + --hash=sha256:25eb661d8ba7e2b85ebb4e7a1bb9f6a3280910ed10b84330a7abfb5ed32d2764 \ + --hash=sha256:29b615c2d5dd883f89c107a782681b73a7aa8b2bd894130310e45eb09b143913 \ + --hash=sha256:2a9cb5bbfc33b4ebd3c8d3714d6e09805437f60a14977774e4bd11b5ace46c23 \ + --hash=sha256:2b23dacf7a8a88afe92f77bcdbf4cf043a84692bac2f2da02f4011c687e19b48 \ + --hash=sha256:2fd562372d4afaf18d1c7f1c68e39bd32818bb16d5f57f778a4a4ed88eedcaae \ + --hash=sha256:32110d954d9d0e51f36edb0396673bf4d35193a0a80ba7013bf7f7d58a33ca07 \ + --hash=sha256:332b88022d0acd47c44158bcd43f47f14eaddcd979247f242b1a33291c45cdd1 \ + --hash=sha256:361f9a21a33191d5287e7a75f508fc3b9bf47aaffb0241198d6b7be3384f294e \ + --hash=sha256:37ed7782972545e67d97ef9a172e9499d25459b1e32cb99d1c2a30c572f2441b \ + --hash=sha256:3acc45494c08a4d27375a2c28474537f68910219f42624a08011221b04623d04 \ + --hash=sha256:3b76837cd051d8875913f3934b27205f7a592f78b8a4f4a1f3abc36a2420c5bc \ + --hash=sha256:3b78b39377630a70aa2198e5f03be416e690364a046b1d8af80a9263733083d0 \ + --hash=sha256:3d27153a0413696767b98b5d96173a2a7e27cbfca02000a6069cea7b977765a7 \ + --hash=sha256:3d512e3a6c528c42ce220ed9c3caff83c6fdcd98a1b98249f08ccfc66ad111b1 \ + --hash=sha256:4073ab7cefe5a2e7dc368c1f01eb27716dc8ea8f1c69e9b7725ebce05aa53cf7 \ + --hash=sha256:42d7a6e818214336cf7af9a3aae5c6923f94f09fa07ac5aab98268ba2a98a6d5 \ + --hash=sha256:4441d655a53185ef1b01b1d42ad9b3a96b905110a9219043e3fdaec855ba3b6e \ + --hash=sha256:47beedf07a706140b50786457e082497ee6b5970c41e49bcd1572ee3f4c73f80 \ + --hash=sha256:48c83103f82fce36191a9e3a8a80731d4340e2da544eae8c5aaaf6a45280e7d0 \ + --hash=sha256:4b79ea31cf277c72d2307c6cb8bd32a2c151959e80a2f24fa85df620c5f2860c \ + --hash=sha256:4c792e6cce24d08f92199d907785c9e64f74c357deeed3dbaf7d699ed5930b64 \ + --hash=sha256:4d2636a5b61c69e32db0e74b57073dd4a66f1ad195e9398c5f01bba41090deea \ + --hash=sha256:4fb2db1f649b6464711846b5bb9c1611bd906ce939d9460cbd1fa419552cd6c3 \ + --hash=sha256:506623128d7a55b726a5a46e4c9a8eae4c9dd0f04ff60b041fe7df85b9d034fe \ + --hash=sha256:51adfc30f467077293a6695d8149bcef501c0728386f3a491054f835dadb60b2 \ + --hash=sha256:52c31d123f5adf14dec694c2a4036bf42c523df7cefc641d31cd05653de56e49 \ + --hash=sha256:5830f94a7190a33e55d06d89242ea951ee2009c3a27fc31538f3ac9386b27edd \ + --hash=sha256:5cec3e7fbc911ff4fcf43b24aaf1b96906381ad36030f42897def3ff4b78729e \ + --hash=sha256:5da7f7f1ecf866186f195f7ecc374c915e1838496b86f2f3b41da27c798a25c3 \ + --hash=sha256:5ec906bb21b35e66cfdcc2ea0133f249f52b7d89e3e45ec8d2bd2d84a0255d7d \ + --hash=sha256:5f772007dfba8941873dbd276b6a6845428cc740888b5c6c37bf483b1ea34b42 \ + --hash=sha256:648adde345cfd13335342e23cfe94871c7f7f55a43fb254f3b95acb071072968 \ + --hash=sha256:6531540aa63e6864cb0615a3a5da70ae03191334507852a5385320bb7916df3f \ + --hash=sha256:6597a1f7413189b4bc58437babcdf39b79da416e5c477ae98323a88ecc108188 \ + --hash=sha256:6686d29eaafddeeab9e48af92103dc0d26161035faa4be083b8b488f1ab47219 \ + --hash=sha256:679eef0123861ca381dd8fd9bdc1920e87636ac55eae6cff875c53dcb05fbcf8 \ + --hash=sha256:6afc32a7a1a40b071070419537df3559d26907086556769683fa97b7b6e90fd5 \ + --hash=sha256:6b061ea7ca94966af7cd7a87cb189ff15972c103244611a2b1e8408c04f1dbe6 \ + --hash=sha256:6cf4c002268da5c11cae9083aec71ac943bce9d95e0f83c8bf9a95a3ce421fbb \ + --hash=sha256:6e6558efbb256b532c9f06a21ba42bcac729718e365c505819f181f25e5c0eca \ + --hash=sha256:6fd8cac4907fec8302726d49e6969acfffaf720bc39b1cee23f473b81f4dce01 \ + --hash=sha256:70293c25d79d1f084fdd665894481547575e429d2a75e8d72caec2f6b6b07af8 \ + --hash=sha256:71f76bb8e59be847330b8a8d3753f85397677e128f9f9692c215f8cf721cefc7 \ + --hash=sha256:725be1aff55f1c873a79cea0f612df9dff21a9d2c2e05783665b824b8aa7c24e \ + --hash=sha256:72d0a731eccf482d5661c6711abfcd5921f3dbfbbb0789061e1918ef4bc89c70 \ + --hash=sha256:7390e060f830b3d132f484b21ac2783a2d227b682ef62bb8146e1c1b4ca0e45a \ + --hash=sha256:73b00645316121105274fd266ed54a20e6514650126d399cdea39876ab1e7b04 \ + --hash=sha256:74e46409666c358b93fce0d8c6f4cb107d8dd7cba1302a38d49adf6fa8193c7e \ + --hash=sha256:75b32cf33ea40f945f226f97bc1d43151e1a5adff21f420b5e63c468b1a28114 \ + --hash=sha256:769e4f7b090e10cae4c082e3e39b9458ec2444505df84953c16c0468b16539c4 \ + --hash=sha256:7849a096d6fdba3f2bdf849eb89f2d233a24400c92faa3efb25fa8dc615b706c \ + --hash=sha256:799c5a23126c9589cb10b6c6b54f9087246940dad20cd74bf3f06fc9ae3bbab5 \ + --hash=sha256:7a7e9321822ca59ba56fa8744c3e3f01ef74d547aafe0d383d000d7a4d11eb79 \ + --hash=sha256:7b23db4b469d9e051ac74d96db9bf61e27c9054de64f8263fefa32bf72867ba1 \ + --hash=sha256:7b4ee34acefa9ba1333f02b817982f1e4127f6f9b975017a707c4e738d7f9fb9 \ + --hash=sha256:829bcaa90be8737c132d9515d6a466e8528e3b3d050d3f3466f36ea94d3beb48 \ + --hash=sha256:8588edf317fbe4f16a949bc5a6ccdd22b6e6fd5b4745d128bc40bccf31024ce5 \ + --hash=sha256:868744c68e9342768888c7326604da2228c19bb6946aa46b5f9e1ac32ea8d40b \ + --hash=sha256:88ba8a439b2bad97bb69c38db5bf0ec16a061ee834cf0f244089186ee803e899 \ + --hash=sha256:8b45d40f07081b377f0b837ddbfba4a9ab4a89a7b569d3c095436564e767a27b \ + --hash=sha256:8c00c5c49f80a588a2c4f99d519d2abed4ce26cdca11273041bc8b7e60af60de \ + --hash=sha256:8c51ac7b31578a7275650f580aac985940985b6d2e57247ac6d5e07f2c936cd6 \ + --hash=sha256:8d148b4b295224d3a19e7bd9f02ebe7344de254b3b2c4d0628ce42ade0bca119 \ + --hash=sha256:8f32a6cb0b101170abc2d8053af1473a2a29ef2a06d7130994ee8455a50f5dbf \ + --hash=sha256:8fd30d217a4275107667fe1d4f601e2d50c797d0727148cfbb384536d65f04d3 \ + --hash=sha256:927d602fb8fb595a69e6b1ae998a6ec19851d0fb9391759456e6425090996647 \ + --hash=sha256:94622cf24e80d03054ceb69a06a86c950e12fe993ed06b933f7895232cacbc3e \ + --hash=sha256:954d43574f72be244b65f0fa61e2f814214b024f2ec849f487aa2f553f7c6e71 \ + --hash=sha256:98f16db5e79f2605194c0f899d0532005bb12269e698bf96f62a5635bcf33010 \ + --hash=sha256:9a4e95d56994a45ac76541853796720436ffe462957d0b1b89e87ff3d3b1627e \ + --hash=sha256:9ae1429ee9e52b139167ed0169757806a27ed7307e942e8c62037a9184e9843b \ + --hash=sha256:9e282f35ece8895b9fa5af406fc6a4cb42f01e1009525cf46641de584781c83b \ + --hash=sha256:9ff89e36451a19775e7556d95ebfa4a0b24b84cc87565d6b5d0ec8ea82bf3f2c \ + --hash=sha256:a0457561e8a87a6a3b509e21c35084d7d207ced262139de3f738e9676a476bbd \ + --hash=sha256:a50bd9b44dd9b7b845802030b4d88610c01c7f95571b4cbd3a1aeec17a34c9fd \ + --hash=sha256:a703f23ca15a51251fc94f1774683f5aa511588a7ad6bbb83960cdb2a4dcd978 \ + --hash=sha256:a920e99155d75d1b1c08172da8aaa7200e7b30871fb628d1631d7891a333e30e \ + --hash=sha256:a9b0026538719f7a532998d84640ab2e91ca1a051ce4b8d924fdcb607307e921 \ + --hash=sha256:ab9e4bd60796866c578e3afa0a17c98636887827a39c2e238a61989c18fd8e7b \ + --hash=sha256:accb5e9492b9bc9c9aeec5b72ea8f2ddcbefc6143d1c626fba960ecbdc89807e \ + --hash=sha256:ad2e9e152bec6c4f7e4cdb4adcea6b708400090eec04bfe5ee5b30d368ffd721 \ + --hash=sha256:adc30e5d064c9e33049e825408fa4e952f4e8a0cf4c329dc2ded72e1bb5bae08 \ + --hash=sha256:b529ad6c81ad21db2f4e3d6e33db78ce3b9f272066b438b5d673361f9282898d \ + --hash=sha256:b6c7f91520c29420639925fd1cecceac9ca07366b50c09ea24eac1cd116b4866 \ + --hash=sha256:b7ea61af293a153f2d67430038cf0efc10140caa623f2bdf5a9f5a457ff20f57 \ + --hash=sha256:b8451f31ae24620cc9372acd2728d87922f2bb0cae3fa57aed68b152844cdf08 \ + --hash=sha256:bbb6bed49f083e2ec8ae0bb21c00c7c5171ab8e08a8b008c61a43fc6c944fdac \ + --hash=sha256:bc581579885728bb54ef15000295c20e6a64a200b1b73d5cfca71879a3eb3e1f \ + --hash=sha256:bcad49104711f88b2b7c3ba166de172857d837248205e541b044a552558fecc3 \ + --hash=sha256:bfc86dff091104eec770c29ea1831dbe191968a70aa2d8ae8eda3240d863970a \ + --hash=sha256:c00f0c45851a2c61efffd03dde8fb4d594026b5cc18196c94e15460f1225f2ed \ + --hash=sha256:c0cc2cb47face4dae64bd70a9268dbebddb3e48c1b48cf6d7b6ac1471609fd88 \ + --hash=sha256:c2db7b9e2d726f7e051bedb67abcf460b84b11437b2f9fdacda7e6931dace85b \ + --hash=sha256:c44776f82b04e623eb9e1b81fcd17ac6e9808ae1ba5cdf8edce1f7c869a95a9b \ + --hash=sha256:c564d1e6531611bf87078c28780456ecc75459dcca189629fff2fe7c020d8e67 \ + --hash=sha256:c568e72f5425a74616daeb824afe28da460cbeea036432f41c6af5380b83300d \ + --hash=sha256:c5e970d3996c597186c9dbb6e77a8048737a834ac03cb13186a54999e0e1c31e \ + --hash=sha256:c634053459278cf31fb404ddb5ecd38124e4609e8b609166da621edc2d5796db \ + --hash=sha256:c63c0d7832956d5f3bb77888b93c854569d13f806289ff75c43926d25c6e5fd2 \ + --hash=sha256:c6bd2c9781915592b209160d0f017d4c7c82ad53075e6b0c24d2a89299e7f9e9 \ + --hash=sha256:c74954409ea5789f7a60daa5f94580c00ad0b60b20751c47b35c4784877b5057 \ + --hash=sha256:c7a56140fcbf4662cb263754a754c651a16fe2196cae1fd232d18f89acd30c83 \ + --hash=sha256:c7b996c02de5e032cb63dc65482b73693b7ecaa586654126be2749ddea239c40 \ + --hash=sha256:c7f66a53e9b7ec831abb15e3d9b6cee8f52d6a24cd0a374786bf46c3ec218bc6 \ + --hash=sha256:c8052c3058c402d53602583da5ddcf1a15a63ab1efe0222f47c7be59944e6463 \ + --hash=sha256:c9f8fdb7bbefc4907c2bb3593f810f2b67a2d5329e1d38724a1aa3d763cf63eb \ + --hash=sha256:cbdf4b42f4514422051889424fd03bc269be8c5d751bf39ec29ad761590bd10d \ + --hash=sha256:cc0cd4065a65ac98f3e2742ee9f314da9d224db49b07dc17d3a20cef0d79658d \ + --hash=sha256:cebad8f4796248b91fc2168515e507e5cb6b663a32befd09f3b1e7920acdcb1a \ + --hash=sha256:cf99debb17227a50620aee16647bb33a0387f5fa5418d628666c0f05745e0d2a \ + --hash=sha256:cfbbba4597f19ad717f79fd3f2a67ae7efae647bd6a61e14554e1b5ad242a5bf \ + --hash=sha256:d0acd8191c0e5a61d1c27bfa33b6ec8431982299f5d71c02129aa4f16f182ad0 \ + --hash=sha256:d0b6a9bd86f865ebc9d847a1eb0a655a0e2b447cbcde743dda97c4298415cc0b \ + --hash=sha256:d0f943b53bb1d7389d72a5380078670e5fcc34a1328142148b72d20fd82031c7 \ + --hash=sha256:d48795c7d64ca1c42e3d61fcc6478cb15a19b6ad3f9c882bc8d8dee49d4a420f \ + --hash=sha256:d8255971f6789cf0cb365e3e22b9b166eba978548a26241eaa5b7af0440d885a \ + --hash=sha256:ddab54dc0ec46ed09ef9ac131252cb5740ed21fbe6fc145f3c6fed0b575a8f15 \ + --hash=sha256:dde9cd67f18803949ebf96de8aba12b8ec5ee3115e34c75a2db85a3b0a6c20d5 \ + --hash=sha256:de29795f23833cdfb68ba45c8a5cc0ec49778ea5deb670188f4465bba5930d25 \ + --hash=sha256:e0feb03a338f8f5e819ae23a73aabb7b46fd3b73f15f7df782dd12d99e2b02ad \ + --hash=sha256:e30550965c78223439815dbdf40c93eb8931dfbf12427110dcfcaeebef76feb6 \ + --hash=sha256:e3e80cad85ba68c07c1e8d4ccc9cf51e63bf6a73d6bf631640be947bd72b6b63 \ + --hash=sha256:e4beaef2f4796558e738e15ca0b8802e35f419a9fa374a978e41706f43c48b26 \ + --hash=sha256:e6d2a185ec4ff0478089305abd833da3a9fda8803eb77325b89ebeae5585a3b7 \ + --hash=sha256:ed7fe197b8bb66eef4f62c052e33752d456d2fdda74805469d4bd954950ab339 \ + --hash=sha256:ee48728a07566c32e097d267f4d4d01b85123c9ae748a42da02185b908cbedc4 \ + --hash=sha256:ee6c4e0085bff5f6621bf9c53452584dcb91ebd993ce841e1f4061ac12b6b556 \ + --hash=sha256:ef03fb4b3685df0ed9dc6a074a090f030345cad2c23c61818da3e08e693a9caa \ + --hash=sha256:f01a680d4b66fd15cc7513fae55c673cc8cc340d41340305b7f9a782abdbefed \ + --hash=sha256:f0494d3d97502e270282cc6a1f8648cd7335b4fe8d5c452006e5b7f9d62b8892 \ + --hash=sha256:f24bed98595b9d854da8d6debaf82754abf3e8fe63ec90505ef560566814df7e \ + --hash=sha256:f42671900e0b602d05a074c52fd3d757c2f8e5c4f73f771794dae1aa4aea5f0e \ + --hash=sha256:f5b1d315a615cec81a79e2ce3a94221dc5c23e7fd4012897085d3b5e5a5638d0 \ + --hash=sha256:f5f06f59e19aa0770aaf651ce91c196fa620b35aed49a7e6c36e7335f3f83db7 \ + --hash=sha256:fcac44d917672ad6257ad64b80b5168306f168a78cab5c0612e4c699caed18cf # via uvicorn diff --git a/backend/tests/test_browser.py b/backend/tests/test_browser.py index 6d157fdc4..25e0a66a9 100644 --- a/backend/tests/test_browser.py +++ b/backend/tests/test_browser.py @@ -30,12 +30,34 @@ from app.models.transfer_operation import TransferOperation, TransferOperationPhase from app.services.content_transfer import SourceChangedError, SourceDeleteError, SourceDeletionOutcomeUnknown from app.services.preprocessor import PreprocessorFileTooLargeError +from app.storage.smb_errors import SMBMutationUncertain PUBLISH_CONTRACT = json.loads( (Path(__file__).resolve().parents[2] / "archive-contract/v2/fixtures/browser-file-publish-scenarios-v2.json").read_text() )["staged_publish"] +@pytest.mark.parametrize("operation", ["delete", "rename"]) +def test_uncertain_smb_mutation_returns_non_replayable_conflict(client, auth_headers_user, test_connection, mock_smb_backend, operation): + _backend_class, backend = mock_smb_backend + if operation == "delete": + backend.delete_item.side_effect = SMBMutationUncertain() + response = client.delete(f"/api/browse/{test_connection.id}/item", headers=auth_headers_user, params={"path": "/document.txt"}) + else: + backend.rename_item.side_effect = SMBMutationUncertain() + response = client.post( + f"/api/browse/{test_connection.id}/rename", headers=auth_headers_user, json={"path": "/document.txt", "new_name": "renamed.txt"} + ) + assert response.status_code == 409 + assert response.json()["detail"] == { + "code": "smb_mutation_uncertain", + "message": "SMB change confirmation was lost. Check the source before retrying; the change may already be applied.", + "recoverable": False, + "mutation_uncertain": True, + } + backend.disconnect.assert_awaited_once() + + @pytest.mark.asyncio async def test_cross_connection_progress_bounds_pending_sends_and_preserves_completion() -> None: started = asyncio.Event() diff --git a/backend/tests/test_directory_cache.py b/backend/tests/test_directory_cache.py index 1bca16ad1..06985442a 100644 --- a/backend/tests/test_directory_cache.py +++ b/backend/tests/test_directory_cache.py @@ -1,32 +1,23 @@ -""" -Tests for the directory cache service (instant directory navigation). - -Tests cover: -- CacheState enum values -- ConnectionDirectoryCache: search, add, remove, rename, state management -- DirectoryCacheManager: get/create/remove caches, stats, shutdown -- Module-level singleton and shutdown functions -""" - -import asyncio -import threading +"""SQLite directory-cache binding, watcher, manager, and shutdown checks.""" + +import io +import logging +from dataclasses import replace from unittest.mock import AsyncMock, MagicMock, patch import pytest -from smbclient._os import FileAttributes -from smbprotocol.reparse_point import ReparseTags +import pytest_asyncio +from app.core.config import static +from app.core.logging import RecoveryLogFormatter from app.services.directory_cache import ( CHANGE_NOTIFY_BUFFER_SIZE, MAX_SEARCH_RESULTS, - RESCAN_INTERVAL_SECONDS, - SCAN_BATCH_SIZE, SCAN_TIMEOUT_SECONDS, CacheState, ConnectionDirectoryCache, DirectoryCacheManager, - _DirectoryMutation, - _ScannedDirectory, + SQLiteConnectionDirectoryCache, get_directory_cache_manager, shutdown_directory_cache, ) @@ -37,10 +28,11 @@ @pytest.fixture(name="cache") -def cache_fixture() -> ConnectionDirectoryCache: - """Create a ConnectionDirectoryCache instance for testing.""" +def cache_fixture(tmp_path, monkeypatch): + """Create an isolated SQLite source with retained shutdown ownership.""" - return ConnectionDirectoryCache( + monkeypatch.setattr("app.services.directory_cache.settings.directory_cache_location", str(tmp_path / "cache")) + cache = SQLiteConnectionDirectoryCache( connection_id="test-conn-001", host="server.local", share_name="testshare", @@ -48,6 +40,117 @@ def cache_fixture() -> ConnectionDirectoryCache: password="testpass", port=445, ) + yield cache + cache.stop() + + +def test_watcher_logs_omit_source_identity_and_private_error_text(cache, monkeypatch): + logger = MagicMock() + monkeypatch.setattr("app.services.directory_cache.logger", logger) + private_error = f"{cache.host}/{cache.share_name} {cache.username} {cache.password} private-source-path" + failure = OSError(private_error) + monkeypatch.setattr(cache, "_connect_watcher", MagicMock(side_effect=failure)) + monkeypatch.setattr(cache._stop_event, "wait", lambda _delay: True) + cache._watcher_loop() + for attribute, method in ( + ("_watcher_open", "close"), + ("_watcher_tree", "disconnect"), + ("_watcher_session", "disconnect"), + ("_watcher_connection", "disconnect"), + ): + resource = MagicMock() + getattr(resource, method).side_effect = failure + setattr(cache, attribute, resource) + cache._cleanup_watcher() + cache.stop() + logged = repr(logger.mock_calls) + for private_value in (cache.connection_id, cache.host, cache.share_name, cache.username, cache.password, private_error): + assert private_value not in logged + assert "OSError" in logged + logger.exception.assert_not_called() + + +@pytest.mark.parametrize("watcher_was_ready", [False, True]) +def test_failed_watcher_retries_preserve_fallback_scan(cache, monkeypatch, watcher_was_ready): + from app.services.directory_cache import MAX_WATCHER_RETRIES + + if watcher_was_ready: + cache._watcher_ready.set() + initial_epoch = cache.coordinator.epoch + baseline = None + attempts = 0 + + def wait_for_retry(_delay): + nonlocal baseline, attempts + attempts += 1 + if baseline is None: + assert cache.coordinator.epoch == initial_epoch + 1 + baseline = cache.coordinator.begin_read("") + else: + assert cache.coordinator.is_current(baseline) + return attempts > MAX_WATCHER_RETRIES + + monkeypatch.setattr(cache, "_connect_watcher", MagicMock(side_effect=OSError("Directory notifications are unavailable"))) + monkeypatch.setattr(cache._stop_event, "wait", wait_for_retry) + try: + cache._watcher_loop() + assert attempts > MAX_WATCHER_RETRIES + finally: + if baseline is not None: + cache.coordinator.end_read(baseline) + + +def test_recovery_logs_have_context_one_cause_and_bounded_failure_volume(cache, monkeypatch): + from app.services.directory_cache import MAX_WATCHER_RETRIES + + output = io.StringIO() + logger = logging.Logger("app.services.directory_cache", level=logging.DEBUG) + handler = logging.StreamHandler(output) + handler.setFormatter(RecoveryLogFormatter("%(message)s")) + logger.addHandler(handler) + monkeypatch.setattr("app.services.directory_cache.logger", logger) + monkeypatch.setattr(cache, "_connect_watcher", MagicMock(side_effect=OSError("private source and password"))) + monkeypatch.setattr(cache._stop_event, "wait", MagicMock(side_effect=[False] * (MAX_WATCHER_RETRIES + 4) + [True])) + cache._watcher_loop() + logs = output.getvalue() + assert logs.count("Recovery traceback:") == 1 + assert logs.count("event='directory_watcher_retry'") == MAX_WATCHER_RETRIES + assert logs.count("event='directory_watcher_retry_exhausted'") == 1 + assert "private source" not in logs + assert cache.connection_id not in logs + assert f"source_id='{cache._log_context['source_id']}'" in logs + assert f"generation_id='{cache._diagnostic_generation}'" in logs + assert "retry_seconds=" in logs + cache._schedule_retry() + cache._reset_retry() + recovered = output.getvalue() + assert recovered.count("event='directory_verification_recovered'") == 1 + cache._reset_retry() + assert output.getvalue() == recovered + + +@pytest.mark.asyncio +@pytest.mark.parametrize("operation", ["remove_cache_async", "stop_all_async", "stop_all"]) +async def test_manager_shutdown_logs_omit_source_identity_and_private_error_text(cache, monkeypatch, operation): + logger = MagicMock() + manager = DirectoryCacheManager() + manager._caches[cache.connection_id] = cache + failure = OSError(f"{cache.host}/{cache.share_name} {cache.username} {cache.password} private-source-path") + with monkeypatch.context() as scope: + scope.setattr("app.services.directory_cache.logger", logger) + scope.setattr(cache, "_finish_stop", MagicMock(side_effect=failure)) + if operation == "remove_cache_async": + await manager.remove_cache_async(cache.connection_id) + elif operation == "stop_all_async": + await manager.stop_all_async() + else: + manager.stop_all() + logged = repr(logger.mock_calls) + for private_value in (cache.connection_id, cache.host, cache.share_name, cache.username, cache.password, str(failure)): + assert private_value not in logged + assert "OSError" in logged + assert "exc_info" not in logged + logger.exception.assert_not_called() @pytest.fixture(name="populated_cache") @@ -75,15 +178,23 @@ def populated_cache_fixture(cache: ConnectionDirectoryCache) -> ConnectionDirect "backups/daily", "backups/weekly", ] - cache.add_directories(dirs) + cache.index.open() + cache._opened.set() + cache.index.write_paths(dirs) + cache.index.finish_generation(1) + cache._directory_count = len(dirs) + cache._source_available.set() return cache -@pytest.fixture(name="manager") -def manager_fixture() -> DirectoryCacheManager: +@pytest_asyncio.fixture(name="manager") +async def manager_fixture(tmp_path, monkeypatch): """Create a DirectoryCacheManager instance for testing.""" - return DirectoryCacheManager() + monkeypatch.setattr("app.services.directory_cache.settings.directory_cache_location", str(tmp_path / "cache")) + manager = DirectoryCacheManager() + yield manager + await manager.stop_all_async() # ============================================================================ @@ -130,17 +241,6 @@ def test_max_search_results(self): assert MAX_SEARCH_RESULTS > 0 assert isinstance(MAX_SEARCH_RESULTS, int) - def test_scan_batch_size(self): - """SCAN_BATCH_SIZE should be a positive integer.""" - - assert SCAN_BATCH_SIZE > 0 - assert isinstance(SCAN_BATCH_SIZE, int) - - def test_rescan_interval(self): - """RESCAN_INTERVAL_SECONDS should be at least 60 seconds.""" - - assert RESCAN_INTERVAL_SECONDS >= 60 - def test_scan_timeout(self): """SCAN_TIMEOUT_SECONDS should be positive.""" @@ -191,10 +291,10 @@ def test_stores_connection_params(self, cache: ConnectionDirectoryCache): assert cache.password == "testpass" assert cache.port == 445 - def test_default_port(self): + def test_default_port(self, cache): """Port should default to 445 when not specified.""" - cache = ConnectionDirectoryCache( + cache = SQLiteConnectionDirectoryCache( connection_id="test", host="host", share_name="share", @@ -202,991 +302,90 @@ def test_default_port(self): password="pass", ) assert cache.port == 445 + cache.stop() - -# ============================================================================ -# ConnectionDirectoryCache — search -# ============================================================================ - - -@pytest.mark.unit -class TestSearch: - """Tests for the search method.""" - - def test_word_boundary_ranks_before_substring_and_normalizes_accents(self, cache: ConnectionDirectoryCache): - cache.add_directories(["xreport", "annual-report", "report", "Résumé"]) - matches, count = cache.search("report") - assert matches == ["report", "annual-report", "xreport"] - assert count == 3 - assert cache.search("resume")[0] == ["Résumé"] - - def test_empty_query_returns_empty(self, populated_cache: ConnectionDirectoryCache): - """Empty query should return no results.""" - - assert populated_cache.search("") == ([], 0) - - def test_no_matches_returns_empty(self, populated_cache: ConnectionDirectoryCache): - """Query that matches nothing should return empty list.""" - - assert populated_cache.search("nonexistent_xyz") == ([], 0) - - def test_finds_substring_match(self, populated_cache: ConnectionDirectoryCache): - """Substring match should find directories containing the query.""" - - results, _ = populated_cache.search("rock") - assert "music/rock" in results - - def test_case_insensitive(self, populated_cache: ConnectionDirectoryCache): - """Search should be case-insensitive.""" - - results_lower, _ = populated_cache.search("documents") - results_upper, _ = populated_cache.search("DOCUMENTS") - results_mixed, _ = populated_cache.search("Documents") - - # All searches should return the same set of results - assert set(results_lower) == set(results_upper) - assert set(results_lower) == set(results_mixed) - assert len(results_lower) > 0 - - def test_finds_multiple_matches(self, populated_cache: ConnectionDirectoryCache): - """Query matching multiple paths should return all matches.""" - - results, _ = populated_cache.search("2024") - assert len(results) >= 2 - assert "documents/work/reports/2024" in results - assert "photos/vacation/2024" in results - - def test_relevance_exact_basename_first(self, populated_cache: ConnectionDirectoryCache): - """Exact basename match should rank highest.""" - - results, _ = populated_cache.search("music") - # "music" (exact basename match) should come before "music/rock" etc. - assert results[0] == "music" - - def test_relevance_basename_starts_with_before_contains(self): - """Basename starting with query should rank above path-contains.""" - - cache = ConnectionDirectoryCache( - connection_id="test", - host="h", - share_name="s", - username="u", - password="p", - ) - cache.add_directories( - [ - "deep/nested/report", # basename starts with "report" - "reports", # exact basename match - "some/path/quarterly-report", # basename contains "report" - ] - ) - - results, _ = cache.search("report") - # "reports" is exact match -> tier 0 - # "deep/nested/report" is exact basename match -> tier 0 - # "some/path/quarterly-report" is basename-contains -> tier 2 - assert results[-1] == "some/path/quarterly-report" - - def test_relevance_shorter_paths_preferred(self): - """Among same-tier matches, shorter (shallower) paths rank higher.""" - - cache = ConnectionDirectoryCache( - connection_id="test", - host="h", - share_name="s", - username="u", - password="p", - ) - cache.add_directories( - [ - "a/b/c/docs", - "docs", - "x/docs", - ] - ) - - results, _ = cache.search("docs") - # All are exact basename matches, sorted by depth - assert results[0] == "docs" - assert results[1] == "x/docs" - assert results[2] == "a/b/c/docs" - - def test_search_caps_at_max_results(self): - """Search should return at most MAX_SEARCH_RESULTS.""" - - cache = ConnectionDirectoryCache( - connection_id="test", - host="h", - share_name="s", - username="u", - password="p", - ) - # Add more directories than the cap - dirs = [f"dir_{i}" for i in range(MAX_SEARCH_RESULTS + 100)] - cache.add_directories(dirs) - - results, total_count = cache.search("dir") - assert len(results) == MAX_SEARCH_RESULTS - assert total_count == MAX_SEARCH_RESULTS + 100 - - def test_search_on_empty_cache(self, cache: ConnectionDirectoryCache): - """Search on empty cache should return empty list.""" - - assert cache.search("anything") == ([], 0) - - def test_search_partial_path_segment(self, populated_cache: ConnectionDirectoryCache): - """Should match partial segments within path components.""" - - results, _ = populated_cache.search("vac") - matching_paths = [r for r in results if "vacation" in r] - assert len(matching_paths) > 0 - - def test_search_slash_in_query(self, populated_cache: ConnectionDirectoryCache): - """Query containing / should match path separator.""" - - results, _ = populated_cache.search("music/rock") - assert "music/rock" in results - - def test_search_backslash_normalized_to_forward_slash(self, populated_cache: ConnectionDirectoryCache): - """Query containing \\ should be normalised to / and match across directories.""" - - results, _ = populated_cache.search("music\\rock") - assert "music/rock" in results - - def test_search_mixed_separators_normalized(self): - """Query with mixed / and \\ should normalise all to /.""" - - cache = ConnectionDirectoryCache( - connection_id="test", - host="h", - share_name="s", - username="u", - password="p", - ) - cache.add_directories(["a/b/c/d"]) - - results, total = cache.search("a\\b/c") - assert results == ["a/b/c/d"] - assert total == 1 - - -# ============================================================================ -# ConnectionDirectoryCache — add_directory -# ============================================================================ - - -@pytest.mark.unit -class TestAddDirectory: - """Tests for the add_directory method.""" - - def test_add_single_directory(self, cache: ConnectionDirectoryCache): - """Adding a directory should increase count and make it searchable.""" - - cache.add_directory("photos") - assert cache.directory_count == 1 - assert cache.search("photos") == (["photos"], 1) - - def test_normalizes_leading_slash(self, cache: ConnectionDirectoryCache): - """Leading slashes should be stripped.""" - - cache.add_directory("/photos") - results, _ = cache.search("photos") - assert results == ["photos"] - - def test_normalizes_trailing_slash(self, cache: ConnectionDirectoryCache): - """Trailing slashes should be stripped.""" - - cache.add_directory("photos/") - results, _ = cache.search("photos") - assert results == ["photos"] - - def test_normalizes_both_slashes(self, cache: ConnectionDirectoryCache): - """Both leading and trailing slashes should be stripped.""" - - cache.add_directory("/photos/") - results, _ = cache.search("photos") - assert results == ["photos"] - - def test_empty_path_ignored(self, cache: ConnectionDirectoryCache): - """Empty string (root) should not be added.""" - - cache.add_directory("") - assert cache.directory_count == 0 - - def test_slash_only_ignored(self, cache: ConnectionDirectoryCache): - """Slash-only path (root) should not be added.""" - - cache.add_directory("/") - assert cache.directory_count == 0 - - def test_duplicate_single_entry(self, cache: ConnectionDirectoryCache): - """Adding same path twice should not create duplicate.""" - - cache.add_directory("photos") - cache.add_directory("photos") - assert cache.directory_count == 1 - - def test_updates_count(self, cache: ConnectionDirectoryCache): - """Directory count should update after each add.""" - - cache.add_directory("a") - assert cache.directory_count == 1 - cache.add_directory("b") - assert cache.directory_count == 2 - cache.add_directory("c") - assert cache.directory_count == 3 - - -# ============================================================================ -# ConnectionDirectoryCache — add_directories (batch) -# ============================================================================ - - -@pytest.mark.unit -class TestAddDirectories: - """Tests for the add_directories method (batch add).""" - - def test_add_multiple(self, cache: ConnectionDirectoryCache): - """Batch add should add all directories.""" - - cache.add_directories(["a", "b", "c"]) - assert cache.directory_count == 3 - - def test_normalizes_all(self, cache: ConnectionDirectoryCache): - """Batch add should normalize all paths.""" - - cache.add_directories(["/a/", "/b", "c/"]) - results_a, _ = cache.search("a") - results_b, _ = cache.search("b") - results_c, _ = cache.search("c") - assert results_a == ["a"] - assert results_b == ["b"] - assert results_c == ["c"] - - def test_deduplicates(self, cache: ConnectionDirectoryCache): - """Batch add should not create duplicates.""" - - cache.add_directories(["a", "a", "b", "b", "c"]) - assert cache.directory_count == 3 - - def test_empty_list(self, cache: ConnectionDirectoryCache): - """Empty list should be a no-op.""" - - cache.add_directories([]) - assert cache.directory_count == 0 - - def test_all_empty_paths(self, cache: ConnectionDirectoryCache): - """List of empty/root paths should be a no-op.""" - - cache.add_directories(["", "/", ""]) - assert cache.directory_count == 0 - - def test_mixed_valid_and_empty(self, cache: ConnectionDirectoryCache): - """Should add valid paths and skip empty ones.""" - - cache.add_directories(["", "valid", "/", "also_valid"]) - assert cache.directory_count == 2 - - def test_merges_with_existing(self, cache: ConnectionDirectoryCache): - """Batch add should merge with existing directories.""" - - cache.add_directory("existing") - cache.add_directories(["new1", "new2"]) - assert cache.directory_count == 3 - - -# ============================================================================ -# ConnectionDirectoryCache — remove_directory -# ============================================================================ - - -@pytest.mark.unit -class TestRemoveDirectory: - """Tests for the remove_directory method.""" - - def test_remove_single(self, populated_cache: ConnectionDirectoryCache): - """Removing a leaf directory should remove it from cache.""" - - initial = populated_cache.directory_count - populated_cache.remove_directory("music/jazz") - assert populated_cache.directory_count == initial - 1 - assert populated_cache.search("jazz") == ([], 0) - - def test_remove_with_children(self, populated_cache: ConnectionDirectoryCache): - """Removing a parent should remove all children too.""" - - populated_cache.remove_directory("documents") - results, _ = populated_cache.search("documents") - assert len(results) == 0 - assert populated_cache.search("reports") == ([], 0) - - def test_remove_normalizes_path(self, cache: ConnectionDirectoryCache): - """Remove should normalize the path before matching.""" - - cache.add_directory("photos") - cache.remove_directory("/photos/") - assert cache.directory_count == 0 - - def test_remove_nonexistent_is_safe(self, cache: ConnectionDirectoryCache): - """Removing a non-existent path should not raise.""" - - cache.add_directory("existing") - cache.remove_directory("nonexistent") - assert cache.directory_count == 1 - - def test_remove_empty_path_is_noop(self, cache: ConnectionDirectoryCache): - """Empty path should be ignored.""" - - cache.add_directory("something") - cache.remove_directory("") - assert cache.directory_count == 1 - - def test_remove_updates_count(self, populated_cache: ConnectionDirectoryCache): - """Directory count should decrease correctly after removal.""" - - # Count directories starting with "photos" - photos_dirs, _ = populated_cache.search("photos") - initial = populated_cache.directory_count - - populated_cache.remove_directory("photos") - assert populated_cache.directory_count == initial - len(photos_dirs) - - def test_remove_does_not_affect_similarly_named(self, cache: ConnectionDirectoryCache): - """Removing 'abc' should not remove 'abcdef' (non-child prefix).""" - - cache.add_directories(["abc", "abcdef", "abc/child"]) - cache.remove_directory("abc") - # "abc" and "abc/child" removed, but "abcdef" stays (different prefix) - assert cache.directory_count == 1 - assert cache.search("abcdef") == (["abcdef"], 1) - - -# ============================================================================ -# ConnectionDirectoryCache — rename_directory -# ============================================================================ - - -@pytest.mark.unit -class TestRenameDirectory: - """Tests for the rename_directory method.""" - - def test_rename_single(self, cache: ConnectionDirectoryCache): - """Renaming a directory should update its path.""" - - cache.add_directory("old_name") - cache.rename_directory("old_name", "new_name") - assert cache.search("old_name") == ([], 0) - assert cache.search("new_name") == (["new_name"], 1) - assert cache.directory_count == 1 - - def test_rename_updates_children(self, cache: ConnectionDirectoryCache): - """Renaming a parent should update all children paths.""" - - cache.add_directories( - [ - "projects", - "projects/alpha", - "projects/alpha/src", - ] - ) - cache.rename_directory("projects", "work") - - assert cache.search("projects") == ([], 0) - assert "work" in cache.search("work")[0] - assert "work/alpha" in cache.search("alpha")[0] - assert "work/alpha/src" in cache.search("src")[0] - assert cache.directory_count == 3 - - def test_rename_preserves_count(self, cache: ConnectionDirectoryCache): - """Rename should not change the total directory count.""" - - cache.add_directories(["a", "a/b", "a/b/c"]) - cache.rename_directory("a", "x") - assert cache.directory_count == 3 - - def test_rename_normalizes_paths(self, cache: ConnectionDirectoryCache): - """Rename should normalize both old and new paths.""" - - cache.add_directory("photos") - cache.rename_directory("/photos/", "/images/") - assert cache.search("photos") == ([], 0) - assert cache.search("images") == (["images"], 1) - - def test_rename_empty_old_is_noop(self, cache: ConnectionDirectoryCache): - """Empty old path should be a no-op.""" - - cache.add_directory("test") - cache.rename_directory("", "new") - assert cache.search("test") == (["test"], 1) - assert cache.directory_count == 1 - - def test_rename_empty_new_is_noop(self, cache: ConnectionDirectoryCache): - """Empty new path should be a no-op.""" - - cache.add_directory("test") - cache.rename_directory("test", "") - assert cache.search("test") == (["test"], 1) - assert cache.directory_count == 1 - - def test_rename_does_not_affect_similarly_named(self, cache: ConnectionDirectoryCache): - """Renaming 'abc' should not affect 'abcdef'.""" - - cache.add_directories(["abc", "abcdef", "abc/child"]) - cache.rename_directory("abc", "xyz") - - assert "xyz" in cache.search("xyz")[0] - assert "xyz/child" in cache.search("child")[0] - assert "abcdef" in cache.search("abcdef")[0] - assert cache.directory_count == 3 - - -# ============================================================================ -# ConnectionDirectoryCache — start_scan -# ============================================================================ - - -@pytest.mark.unit -class TestStartScan: - """Tests for the start_scan method.""" - - @pytest.mark.asyncio - async def test_sets_building_state(self, cache: ConnectionDirectoryCache): - """start_scan should set state to BUILDING.""" - - with patch.object(cache, "_run_scan", new_callable=AsyncMock): - await cache.start_scan() - assert cache.state == CacheState.BUILDING - - @pytest.mark.asyncio - async def test_skips_if_already_building(self, cache: ConnectionDirectoryCache): - """Should not start a new scan if one is already in progress.""" - - cache._state = CacheState.BUILDING - original_task = cache._scan_task - - await cache.start_scan() - # Should not have created a new task - assert cache._scan_task is original_task - - @pytest.mark.asyncio - async def test_skips_if_updating(self, cache: ConnectionDirectoryCache): - """Should not start a new scan if a rescan is in progress.""" - - cache._state = CacheState.UPDATING - await cache.start_scan() - assert cache._scan_task is None - - @pytest.mark.asyncio - async def test_clears_scan_error(self, cache: ConnectionDirectoryCache): - """start_scan should clear previous scan errors.""" - - cache._scan_error = "Previous error" - with patch.object(cache, "_run_scan", new_callable=AsyncMock): - await cache.start_scan() - assert cache.scan_error is None + @pytest.mark.parametrize("location", ["absolute", "relative", "default"]) + def test_configured_storage_root(self, tmp_path, monkeypatch, location): + """Preserve absolute, data-relative, and default storage locations.""" + data_root = tmp_path / "data" + root = tmp_path / "custom" if location == "absolute" else data_root / ("custom" if location == "relative" else "directory_cache") + configured = str(root) if location == "absolute" else "custom" if location == "relative" else "" + monkeypatch.setattr("app.services.directory_cache.static", replace(static, data_dir=data_root)) + monkeypatch.setattr("app.services.directory_cache.settings.directory_cache_location", configured) + cache = SQLiteConnectionDirectoryCache("fixture", "host", "share", "user", "password") + try: + assert cache._persist_path.parent == root / "sqlite" + cache.index.open() + cache._opened.set() + assert cache._persist_path.is_file() + finally: + cache.stop() # ============================================================================ -# ConnectionDirectoryCache — rescans +# ConnectionDirectoryCache — watcher policy # ============================================================================ -@pytest.mark.unit -class TestRescan: - """Rescans must preserve usable search data when SMB reads fail.""" - - @pytest.mark.asyncio - @pytest.mark.parametrize("failed_path", ["", "documents"]) - async def test_incomplete_rescan_preserves_live_index(self, populated_cache: ConnectionDirectoryCache, failed_path: str): - """A root or subtree timeout must not replace or persist a partial index.""" - cache = populated_cache - cache._state = CacheState.READY - cache._last_scan_time = 123.0 - previous_directories = set(cache._directories) - pool = MagicMock() - - async def scan_directory(_pool, _smb_path, rel_path): - if rel_path == failed_path: - raise TimeoutError("SMB directory read timed out") - return [_ScannedDirectory("documents")] if not rel_path else [] - - with ( - patch("app.services.directory_cache.get_connection_pool", new_callable=AsyncMock, return_value=pool), - patch.object(cache, "_scan_directory_with_pool_lease", side_effect=scan_directory), - patch.object(cache, "save_to_disk") as save, - ): - await cache._run_rescan() - - assert cache._directories == previous_directories - assert cache.state == CacheState.READY - assert cache.last_scan_time == 123.0 - assert cache.scan_error is not None - save.assert_not_called() - - @pytest.mark.asyncio - async def test_unreadable_entry_does_not_publish_partial_rescan(self, populated_cache: ConnectionDirectoryCache): - """An entry metadata failure must not turn a partial listing into a complete index.""" - cache = populated_cache - cache._state = CacheState.READY - previous_directories = cache._directories.copy() - readable_entry = MagicMock() - readable_entry.name = "fresh-directory" - readable_entry.smb_info.file_attributes = FileAttributes.FILE_ATTRIBUTE_DIRECTORY - - class UnreadableEntry: - name = "unreadable-directory" - - @property - def smb_info(self): - raise OSError("Entry metadata could not be read") - - with ( - patch("app.services.directory_cache.get_connection_pool", new_callable=AsyncMock, return_value=MagicMock()), - patch("app.services.directory_cache.smbclient.scandir", return_value=[readable_entry, UnreadableEntry()]), - patch.object( - cache, - "_scan_directory_with_pool_lease", - side_effect=lambda _pool, smb_path, rel_path: cache._scan_single_directory(smb_path, rel_path), - ), - patch.object(cache, "save_to_disk") as save, - ): - await cache._run_rescan() - - assert cache._directories == previous_directories - assert cache.scan_error is not None - save.assert_not_called() - - @pytest.mark.asyncio - async def test_successful_rescan_replaces_index_after_failure(self, populated_cache: ConnectionDirectoryCache): - """Recovery must clear the error and remove paths absent from a complete scan.""" - cache = populated_cache - cache._state = CacheState.READY - - async def scan_directory(_pool, _smb_path, rel_path): - return [_ScannedDirectory("fresh-directory")] if not rel_path else [] - - with ( - patch("app.services.directory_cache.get_connection_pool", new_callable=AsyncMock, return_value=MagicMock()), - patch.object(cache, "_scan_directory_with_pool_lease", side_effect=TimeoutError("Temporary failure")) as scan, - patch.object(cache, "save_to_disk") as save, - ): - await cache._run_rescan() - assert cache.scan_error is not None - scan.side_effect = scan_directory - await cache._run_rescan() - - assert cache._directories == {"fresh-directory"} - assert cache.scan_error is None - assert cache.state == CacheState.READY - save.assert_called_once() - - @pytest.mark.asyncio - async def test_cancelled_rescan_discards_replay_without_losing_live_changes(self, populated_cache: ConnectionDirectoryCache): - """Cancellation must preserve live mutations and allow a later rescan.""" - cache = populated_cache - cache._state = CacheState.READY - started = asyncio.Event() - blocked = asyncio.Event() - - async def scan_directory(_pool, _smb_path, _rel_path): - started.set() - await blocked.wait() - - with ( - patch("app.services.directory_cache.get_connection_pool", new_callable=AsyncMock, return_value=MagicMock()), - patch.object(cache, "_scan_directory_with_pool_lease", side_effect=scan_directory), - patch.object(cache, "save_to_disk") as save, - ): - scan_task = asyncio.create_task(cache._run_rescan()) - await started.wait() - cache.add_directory("created-during-rescan") - scan_task.cancel() - with pytest.raises(asyncio.CancelledError): - await scan_task - - assert "created-during-rescan" in cache._directories - assert cache._rescan_changes is None - assert cache.state == CacheState.READY - save.assert_not_called() - - @pytest.mark.asyncio - async def test_overlapping_rescan_does_not_replace_active_replay(self, cache: ConnectionDirectoryCache): - """Only one rescan may own the live-change journal at a time.""" - cache._state = CacheState.UPDATING - replay = [] - cache._rescan_changes = replay - with patch("app.services.directory_cache.get_connection_pool", new_callable=AsyncMock) as get_pool: - await cache._run_rescan() - - assert cache._rescan_changes is replay - get_pool.assert_not_called() - - @pytest.mark.asyncio - @pytest.mark.parametrize("action", ["add", "remove", "rename"]) - async def test_rescan_preserves_concurrent_mutations(self, cache: ConnectionDirectoryCache, action: str): - """Watcher updates must win over directory data read earlier by a rescan.""" - cache._state = CacheState.READY - cache.add_directory("documents") - pool = MagicMock() - - async def scan_directory(_pool, _smb_path, rel_path): - if not rel_path: - return [_ScannedDirectory("documents"), _ScannedDirectory("photos")] - if rel_path == "documents": - return [_ScannedDirectory("documents/work")] - if rel_path == "photos": - if action == "add": - cache.add_directories(["new-directory", "new-directory/child"]) - elif action == "remove": - cache.remove_directory("documents") - else: - cache.rename_directory("documents", "renamed") - return [] - - with ( - patch("app.services.directory_cache.get_connection_pool", new_callable=AsyncMock, return_value=pool), - patch.object(cache, "_scan_directory_with_pool_lease", side_effect=scan_directory), - patch.object(cache, "save_to_disk") as save, - ): - await cache._run_rescan() - - expected = { - "add": {"documents", "documents/work", "photos", "new-directory", "new-directory/child"}, - "remove": set(), - "rename": {"renamed"}, - } - assert cache._directories == expected[action] - assert cache.directory_count == len(expected[action]) - assert cache._rescan_changes is None - assert cache.state == CacheState.READY - if action == "add": - assert cache.scan_error is None - save.assert_called_once() - else: - assert cache.scan_error is not None - save.assert_not_called() - - @pytest.mark.asyncio - @pytest.mark.parametrize("action", ["rename", "recreate"]) - async def test_rescan_does_not_replay_changes_already_seen_by_scan(self, cache: ConnectionDirectoryCache, action: str): - """Changes before a directory read must not be applied twice to its results.""" - cache._state = CacheState.READY - cache.add_directories(["alpha", "alpha/original", "incoming", "incoming/new-child"]) - - async def get_pool(): - if action == "rename": - cache.rename_directory("alpha", "moved") - cache.rename_directory("incoming", "alpha") - else: - cache.remove_directory("alpha") - cache.add_directory("alpha") - return MagicMock() - - async def scan_directory(_pool, _smb_path, rel_path): - directories = { - "": ["alpha", "moved" if action == "rename" else "incoming"], - "alpha": ["alpha/new-child"], - "moved": ["moved/original"], - "incoming": ["incoming/new-child"], - } - return [_ScannedDirectory(path) for path in directories.get(rel_path, [])] - - with ( - patch("app.services.directory_cache.get_connection_pool", side_effect=get_pool), - patch.object(cache, "_scan_directory_with_pool_lease", side_effect=scan_directory), - patch.object(cache, "save_to_disk") as save, - ): - await cache._run_rescan() - - other = "moved" if action == "rename" else "incoming" - child = "original" if action == "rename" else "new-child" - assert cache._directories == {"alpha", "alpha/new-child", other, f"{other}/{child}"} - assert cache.directory_count == 4 - assert cache.scan_error is None - save.assert_called_once() - - @pytest.mark.asyncio - @pytest.mark.parametrize("action", ["rename", "recreate"]) - @pytest.mark.parametrize("changed_during_read", ["", "alpha"]) - async def test_rescan_keeps_live_index_when_read_is_ambiguous( - self, cache: ConnectionDirectoryCache, action: str, changed_during_read: str +@pytest.mark.parametrize("auth_protocol", ["negotiate", "kerberos"]) +@pytest.mark.parametrize("encrypt", [False, True]) +def test_watcher_uses_validated_smb_policy(cache, auth_protocol, encrypt): + policy = { + "encrypt": encrypt, + "connection_timeout": 19, + "auth_protocol": auth_protocol, + "require_signing": True, + } + with ( + patch("app.services.directory_cache.get_smbclient_policy_kwargs", return_value=policy), + patch("app.services.directory_cache.Connection") as connection, + patch("app.services.directory_cache.Session") as session, + patch("app.services.directory_cache.TreeConnect"), + patch("app.services.directory_cache.Open"), ): - """A namespace change during a read must not replace a correct live index.""" - cache._state = CacheState.READY - cache._last_scan_time = 123.0 - cache.add_directories(["alpha", "alpha/original", "incoming", "incoming/new-child"]) - live_after_changes = set() - - async def scan_directory(_pool, _smb_path, rel_path): - nonlocal live_after_changes - if rel_path == changed_during_read: - if action == "rename": - cache.rename_directory("alpha", "moved") - cache.rename_directory("incoming", "alpha") - else: - cache.remove_directory("alpha") - cache.add_directory("alpha") - live_after_changes = cache._directories.copy() - directories = {"": ["alpha", "incoming"], "alpha": ["alpha/new-child"]} - return [_ScannedDirectory(path) for path in directories.get(rel_path, [])] - - with ( - patch("app.services.directory_cache.get_connection_pool", new_callable=AsyncMock, return_value=MagicMock()), - patch.object(cache, "_scan_directory_with_pool_lease", side_effect=scan_directory), - patch.object(cache, "save_to_disk") as save, - ): - await cache._run_rescan() - - assert live_after_changes - assert cache._directories == live_after_changes - assert cache.last_scan_time == 123.0 - assert cache.scan_error is not None - assert cache._rescan_changes is None - assert cache.state == CacheState.READY - save.assert_not_called() - - @pytest.mark.asyncio - @pytest.mark.parametrize("notification_point", ["later-read", "publication"]) - @pytest.mark.parametrize("action", ["rename", "recreate"]) - async def test_rescan_rejects_delayed_events_for_already_current_reads( - self, cache: ConnectionDirectoryCache, notification_point: str, action: str - ): - """Delayed events must not transform paths that already reflect the namespace changes.""" - cache._state = CacheState.READY - cache._last_scan_time = 123.0 - cache.add_directories(["alpha", "alpha/original", "incoming", "incoming/new-child"]) - pool = MagicMock() - live_after_changes = set() - - def notify(): - nonlocal live_after_changes - if action == "rename": - cache.rename_directory("alpha", "moved") - cache.rename_directory("incoming", "alpha") - else: - cache.remove_directory("alpha") - cache.add_directory("alpha") - live_after_changes = cache._directories.copy() - - async def scan_directory(_pool, _smb_path, rel_path): - if notification_point == "later-read" and rel_path == "unrelated": - notify() - directories = { - "": ["alpha", "moved" if action == "rename" else "incoming", "unrelated"], - "alpha": ["alpha/new-child"], - "moved": ["moved/original"], - "incoming": ["incoming/new-child"], - } - return [_ScannedDirectory(path) for path in directories.get(rel_path, [])] - - async def release_connection(*_args): - if notification_point == "publication": - notify() - return False - - pool.get_connection.return_value.__aexit__.side_effect = release_connection - with ( - patch("app.services.directory_cache.get_connection_pool", new_callable=AsyncMock, return_value=pool), - patch.object(cache, "_scan_directory_with_pool_lease", side_effect=scan_directory), - patch.object(cache, "save_to_disk") as save, - ): - await cache._run_rescan() - - assert live_after_changes - assert cache._directories == live_after_changes - assert cache.last_scan_time == 123.0 - assert cache.scan_error is not None - assert cache.state == CacheState.READY - assert cache._rescan_changes is None - save.assert_not_called() - - @pytest.mark.asyncio - @pytest.mark.parametrize("action", ["add", "remove", "rename"]) - async def test_rescan_applies_changes_after_last_directory_read(self, cache: ConnectionDirectoryCache, action: str): - """Publication accepts additions but rejects ambiguous namespace changes after reads.""" - cache._state = CacheState.READY - cache.add_directories(["documents", "documents/work"]) - pool = MagicMock() - - async def release_connection(*_args): - if action == "add": - cache.add_directory("new-directory") - elif action == "remove": - cache.remove_directory("documents") - else: - cache.rename_directory("documents", "renamed") - return False - - pool.get_connection.return_value.__aexit__.side_effect = release_connection - - async def scan_directory(_pool, _smb_path, rel_path): - directories = {"": ["documents"], "documents": ["documents/work"]} - return [_ScannedDirectory(path) for path in directories.get(rel_path, [])] - - with ( - patch("app.services.directory_cache.get_connection_pool", new_callable=AsyncMock, return_value=pool), - patch.object(cache, "_scan_directory_with_pool_lease", side_effect=scan_directory), - patch.object(cache, "save_to_disk") as save, - ): - await cache._run_rescan() - - expected = { - "add": {"documents", "documents/work", "new-directory"}, - "remove": set(), - "rename": {"renamed", "renamed/work"}, - } - assert cache._directories == expected[action] - assert cache.directory_count == len(expected[action]) - if action == "add": - assert cache.scan_error is None - save.assert_called_once() - else: - assert cache.scan_error is not None - save.assert_not_called() - - @pytest.mark.asyncio - @pytest.mark.parametrize("notification_point", ["read", "publication"]) - async def test_rescan_skips_overlap_checks_for_additions(self, cache: ConnectionDirectoryCache, notification_point: str): - """Additions merge without comparing each path against previously read directories.""" - cache._state = CacheState.READY - pool = MagicMock() - additions = ["documents", "new-directory", "new-directory/child"] - - async def scan_directory(_pool, _smb_path, rel_path): - if rel_path == "documents" and notification_point == "read": - cache.add_directories(additions) - return [_ScannedDirectory("documents")] if not rel_path else [] - - async def release_connection(*_args): - if notification_point == "publication": - cache.add_directories(additions) - return False - - pool.get_connection.return_value.__aexit__.side_effect = release_connection - with ( - patch("app.services.directory_cache.get_connection_pool", new_callable=AsyncMock, return_value=pool), - patch.object(cache, "_scan_directory_with_pool_lease", side_effect=scan_directory), - patch.object(_DirectoryMutation, "invalidates_read", return_value=False) as overlap_check, - patch.object(cache, "save_to_disk") as save, - ): - await cache._run_rescan() - - overlap_check.assert_not_called() - assert cache._directories == set(additions) - assert cache.directory_count == len(additions) - assert cache.state == CacheState.READY - assert cache.scan_error is None - save.assert_called_once() - - @pytest.mark.asyncio - async def test_rescan_accepts_changes_below_listed_children(self, cache: ConnectionDirectoryCache): - """Removing a grandchild doesn't make an immediate-child listing ambiguous.""" - cache._state = CacheState.READY - cache.add_directories(["documents", "documents/work", "documents/work/old"]) - - async def scan_directory(_pool, _smb_path, rel_path): - if not rel_path: - cache.remove_directory("documents/work/old") - directories = {"": ["documents"], "documents": ["documents/work"], "documents/work": ["documents/work/new"]} - return [_ScannedDirectory(path) for path in directories.get(rel_path, [])] - - with ( - patch("app.services.directory_cache.get_connection_pool", new_callable=AsyncMock, return_value=MagicMock()), - patch.object(cache, "_scan_directory_with_pool_lease", side_effect=scan_directory), - patch.object(cache, "save_to_disk") as save, - ): - await cache._run_rescan() - - assert cache._directories == {"documents", "documents/work", "documents/work/new"} - assert cache.scan_error is None - save.assert_called_once() - - @pytest.mark.asyncio - @pytest.mark.parametrize("initial_scan", [False, True]) - async def test_scans_index_junctions_without_traversing_them(self, cache: ConnectionDirectoryCache, initial_scan: bool): - """A junction remains searchable without recursively visiting its target.""" - cache._state = CacheState.READY - pool = MagicMock() - - async def scan_directory(_pool, _smb_path, rel_path): - assert rel_path != "ancestor-junction", "Scanner descended into a directory junction" - if not rel_path: - return [_ScannedDirectory("ancestor-junction", is_reparse_point=True), _ScannedDirectory("ordinary")] - return [] - - with ( - patch("app.services.directory_cache.get_connection_pool", new_callable=AsyncMock, return_value=pool), - patch.object(cache, "_scan_directory_with_pool_lease", side_effect=scan_directory) as scan, - patch.object(cache, "_start_watcher"), - patch.object(cache, "_start_periodic_rescan"), - patch.object(cache, "_start_coalesce_flush"), - patch.object(cache, "save_to_disk"), - ): - if initial_scan: - await cache._run_scan() - else: - await cache._run_rescan() - - assert cache._directories == {"ancestor-junction", "ordinary"} - assert scan.await_count == 2 - - @pytest.mark.parametrize("is_reparse_point", [False, True]) - def test_scan_reads_reparse_attribute(self, cache: ConnectionDirectoryCache, is_reparse_point: bool): - """Directory discovery preserves SMB's reparse-point attribute for traversal decisions.""" - entry = MagicMock() - entry.name = "directory" - entry.smb_info.file_attributes = FileAttributes.FILE_ATTRIBUTE_DIRECTORY - if is_reparse_point: - entry.smb_info.file_attributes |= FileAttributes.FILE_ATTRIBUTE_REPARSE_POINT - with patch("app.services.directory_cache.smbclient.scandir", return_value=[entry]): - result = cache._scan_single_directory("unused", "parent") - - assert result == [_ScannedDirectory("parent/directory", is_reparse_point=is_reparse_point)] - - @pytest.mark.asyncio - @pytest.mark.parametrize("initial_scan", [False, True]) - @pytest.mark.parametrize( - "reparse_tag,should_descend", - [ - (ReparseTags.IO_REPARSE_TAG_DFS, True), - (ReparseTags.IO_REPARSE_TAG_MOUNT_POINT, False), - (ReparseTags.IO_REPARSE_TAG_SYMLINK, False), - (ReparseTags.IO_REPARSE_TAG_RESERVED_ZERO, False), - ], - ) - async def test_scans_distinguish_dfs_from_other_reparse_points( - self, cache: ConnectionDirectoryCache, initial_scan: bool, reparse_tag: int, should_descend: bool + cache._connect_watcher() + connection.assert_called_once_with(guid=None, server_name=cache.host, port=cache.port, require_signing=True) + connection.return_value.connect.assert_called_once_with(timeout=policy["connection_timeout"]) + session.assert_called_once_with( + connection.return_value, + username=cache.username, + password=cache.password, + require_encryption=encrypt, + auth_protocol=auth_protocol, + ) + session.return_value.connect.assert_called_once_with() + cache._cleanup_watcher() + + +@pytest.mark.parametrize("receives_events", [False, True]) +def test_watcher_repeated_post_connect_failure_keeps_backoff(cache, receives_events): + from app.services.directory_cache import MAX_WATCHER_RETRIES, WATCHER_RETRY_BASE_DELAY, WATCHER_RETRY_MAX_DELAY + + attempts = 0 + + def fail(): + nonlocal attempts + attempts += 1 + cache._watcher_received_events = receives_events and attempts == 4 + if cache._watcher_received_events: + cache._watcher_ready.set() + raise ConnectionError("Injected watcher failure.") + + with ( + patch.object(cache, "_connect_watcher") as connect, + patch.object(cache, "_run_watcher_inner", side_effect=fail), + patch.object(cache, "_cleanup_watcher"), + patch.object(cache, "_observation_lost") as lost, + patch.object(cache._stop_event, "wait", side_effect=[False] * (MAX_WATCHER_RETRIES + 4) + [True]) as wait, ): - """Both scan paths follow DFS referrals without traversing other directory reparse points.""" - cache._state = CacheState.READY - referral = MagicMock() - referral.name = "Departments" - referral.smb_info.file_attributes = FileAttributes.FILE_ATTRIBUTE_DIRECTORY | FileAttributes.FILE_ATTRIBUTE_REPARSE_POINT - referral.smb_info.ea_size = reparse_tag - child = MagicMock() - child.name = "Projects" - child.smb_info.file_attributes = FileAttributes.FILE_ATTRIBUTE_DIRECTORY - entries = {"": [referral], "Departments": [child], "Departments/Projects": []} - - async def scan_directory(_pool, smb_path, rel_path): - with patch("app.services.directory_cache.smbclient.scandir", return_value=entries[rel_path]): - return cache._scan_single_directory(smb_path, rel_path) - - with ( - patch("app.services.directory_cache.get_connection_pool", new_callable=AsyncMock, return_value=MagicMock()), - patch.object(cache, "_scan_directory_with_pool_lease", side_effect=scan_directory), - patch.object(cache, "_start_watcher"), - patch.object(cache, "_start_periodic_rescan"), - patch.object(cache, "_start_coalesce_flush"), - patch.object(cache, "save_to_disk"), - ): - if initial_scan: - await cache._run_scan() - else: - await cache._run_rescan() - - expected = {"Departments", "Departments/Projects"} if should_descend else {"Departments"} - assert cache._directories == expected + cache._watcher_loop() + assert connect.call_count == MAX_WATCHER_RETRIES + 5 + assert lost.call_count == 1 + int(receives_events) + delays = [call.args[0] for call in wait.call_args_list] + assert all(0 < delay <= WATCHER_RETRY_MAX_DELAY for delay in delays) + assert delays[1] > delays[0] >= WATCHER_RETRY_BASE_DELAY * 0.9 + assert delays[3] < delays[2] if receives_events else delays[3] > delays[2] + assert not cache._watcher_ready.is_set() # ============================================================================ @@ -1198,12 +397,22 @@ async def scan_directory(_pool, smb_path, rel_path): class TestStop: """Tests for the stop method.""" - def test_stop_clears_directories(self, populated_cache: ConnectionDirectoryCache): - """stop() should clear all cached directories.""" + def test_stop_preserves_persisted_directories(self, populated_cache: SQLiteConnectionDirectoryCache): + """Shutdown closes the writer without deleting committed catalogue rows.""" + from app.services.directory_index import DirectoryIndex - assert populated_cache.directory_count > 0 + count = populated_cache.directory_count + assert count > 0 populated_cache.stop() - assert populated_cache.directory_count == 0 + assert populated_cache._storage_released + with pytest.raises(InterruptedError): + populated_cache.search("documents") + reopened = DirectoryIndex(populated_cache._persist_path, source_kind="smb", source_identity=populated_cache._source_identity) + reopened.open() + try: + assert reopened.status().directory_count == count + finally: + reopened.close() def test_stop_sets_empty_state(self, populated_cache: ConnectionDirectoryCache): """stop() should reset state to EMPTY.""" @@ -1238,136 +447,6 @@ def test_stop_cancels_rescan_task(self, cache: ConnectionDirectoryCache): cache.stop() mock_task.cancel.assert_called_once() - def test_stop_cancels_verification_task(self, cache: ConnectionDirectoryCache): - """Shutdown must also cancel the startup snapshot verification.""" - mock_task = MagicMock() - mock_task.done.return_value = False - cache._verification_task = mock_task - - cache.stop() - - mock_task.cancel.assert_called_once() - - -# ============================================================================ -# ConnectionDirectoryCache — _process_change_events -# ============================================================================ - - -@pytest.mark.unit -class TestProcessChangeEvents: - """Tests for the _process_change_events method.""" - - @staticmethod - def _make_event(action_value: int, file_name: str) -> dict: - """Create a mock CHANGE_NOTIFY event dictionary.""" - - action_mock = MagicMock() - action_mock.get_value.return_value = action_value - name_mock = MagicMock() - name_mock.get_value.return_value = file_name - return {"action": action_mock, "file_name": name_mock} - - def test_file_action_added(self, cache: ConnectionDirectoryCache): - """FILE_ACTION_ADDED should add directory to cache.""" - - from smbprotocol.change_notify import FileAction - - event = self._make_event(FileAction.FILE_ACTION_ADDED, "new_dir") - cache._process_change_events([event]) - assert cache.search("new_dir") == (["new_dir"], 1) - - def test_file_action_removed(self, cache: ConnectionDirectoryCache): - """FILE_ACTION_REMOVED should remove directory from cache.""" - - from smbprotocol.change_notify import FileAction - - cache.add_directory("old_dir") - event = self._make_event(FileAction.FILE_ACTION_REMOVED, "old_dir") - cache._process_change_events([event]) - assert cache.search("old_dir") == ([], 0) - - def test_file_action_renamed(self, cache: ConnectionDirectoryCache): - """Rename events (old + new) should update directory path.""" - - from smbprotocol.change_notify import FileAction - - cache.add_directory("before") - old_event = self._make_event(FileAction.FILE_ACTION_RENAMED_OLD_NAME, "before") - new_event = self._make_event(FileAction.FILE_ACTION_RENAMED_NEW_NAME, "after") - cache._process_change_events([old_event, new_event]) - - assert cache.search("before") == ([], 0) - assert cache.search("after") == (["after"], 1) - - def test_rename_new_without_old_treated_as_add(self, cache: ConnectionDirectoryCache): - """RENAMED_NEW_NAME without preceding RENAMED_OLD_NAME should add.""" - - from smbprotocol.change_notify import FileAction - - event = self._make_event(FileAction.FILE_ACTION_RENAMED_NEW_NAME, "orphan_dir") - cache._process_change_events([event]) - assert cache.search("orphan_dir") == (["orphan_dir"], 1) - - def test_backslash_to_forward_slash(self, cache: ConnectionDirectoryCache): - """Windows-style backslashes should be converted to forward slashes.""" - - from smbprotocol.change_notify import FileAction - - event = self._make_event(FileAction.FILE_ACTION_ADDED, "path\\to\\dir") - cache._process_change_events([event]) - assert cache.search("dir") == (["path/to/dir"], 1) - - def test_malformed_event_does_not_crash(self, cache: ConnectionDirectoryCache): - """Malformed events should be logged and skipped, not crash.""" - - bad_event: dict = {"action": MagicMock(side_effect=Exception("bad")), "file_name": MagicMock()} - # Should not raise - cache._process_change_events([bad_event]) - - -# ============================================================================ -# ConnectionDirectoryCache — thread safety -# ============================================================================ - - -@pytest.mark.unit -class TestThreadSafety: - """Tests for concurrent access to the cache.""" - - def test_concurrent_add_and_search(self, cache: ConnectionDirectoryCache): - """Adding and searching concurrently should not corrupt data.""" - - errors: list[Exception] = [] - - def adder(): - try: - for i in range(100): - cache.add_directory(f"dir_{i}") - except Exception as e: - errors.append(e) - - def searcher(): - try: - for _ in range(100): - cache.search("dir") - except Exception as e: - errors.append(e) - - threads = [ - threading.Thread(target=adder), - threading.Thread(target=searcher), - threading.Thread(target=adder), - threading.Thread(target=searcher), - ] - for t in threads: - t.start() - for t in threads: - t.join(timeout=10) - - assert len(errors) == 0 - assert cache.directory_count > 0 - # ============================================================================ # DirectoryCacheManager @@ -1378,6 +457,33 @@ def searcher(): class TestDirectoryCacheManager: """Tests for the DirectoryCacheManager class.""" + @pytest.mark.asyncio + @pytest.mark.parametrize( + "field,value", + [ + ("host", "other-host"), + ("share_name", "other-share"), + ("username", "other-user"), + ("password", "other-password"), + ("port", 1445), + ("path_prefix", "/other"), + ("source_revision", "new-revision"), + ], + ) + async def test_rebinds_changed_sources_without_serving_previous_paths(self, manager: DirectoryCacheManager, field, value): + parameters = {"connection_id": "rebound", "host": "host", "share_name": "share", "username": "user", "password": "password"} + with ( + patch.object(SQLiteConnectionDirectoryCache, "start_scan", new_callable=AsyncMock), + ): + previous = await manager.get_or_create_cache(**parameters) + previous.add_directory("old-source-data") + replacement = await manager.get_or_create_cache(**{**parameters, field: value}) + assert replacement is not previous + assert previous._stop_event.is_set() + assert replacement.directory_count == 0 + assert manager.get_cache("rebound") is replacement + await manager.stop_all_async() + def test_get_cache_returns_none_for_unknown(self, manager: DirectoryCacheManager): """get_cache should return None for unknown connection IDs.""" @@ -1388,7 +494,7 @@ async def test_get_or_create_cache_creates_new(self, manager: DirectoryCacheMana """get_or_create_cache should create a new cache with scan started.""" with patch( - "app.services.directory_cache.ConnectionDirectoryCache.start_scan", + "app.services.directory_cache.SQLiteConnectionDirectoryCache.start_scan", new_callable=AsyncMock, ) as mock_scan: cache = await manager.get_or_create_cache( @@ -1409,7 +515,7 @@ async def test_get_or_create_cache_returns_existing(self, manager: DirectoryCach """get_or_create_cache should return existing cache on second call.""" with patch( - "app.services.directory_cache.ConnectionDirectoryCache.start_scan", + "app.services.directory_cache.SQLiteConnectionDirectoryCache.start_scan", new_callable=AsyncMock, ): cache1 = await manager.get_or_create_cache( @@ -1433,7 +539,7 @@ async def test_get_cache_after_create(self, manager: DirectoryCacheManager): """get_cache should find cache created by get_or_create_cache.""" with patch( - "app.services.directory_cache.ConnectionDirectoryCache.start_scan", + "app.services.directory_cache.SQLiteConnectionDirectoryCache.start_scan", new_callable=AsyncMock, ): created = await manager.get_or_create_cache( @@ -1451,7 +557,7 @@ async def test_remove_cache_stops_and_removes(self, manager: DirectoryCacheManag """remove_cache should stop the cache and remove it from the manager.""" with patch( - "app.services.directory_cache.ConnectionDirectoryCache.start_scan", + "app.services.directory_cache.SQLiteConnectionDirectoryCache.start_scan", new_callable=AsyncMock, ): await manager.get_or_create_cache( @@ -1478,7 +584,7 @@ async def test_stop_all(self, manager: DirectoryCacheManager): """stop_all should stop every cache and clear the manager.""" with patch( - "app.services.directory_cache.ConnectionDirectoryCache.start_scan", + "app.services.directory_cache.SQLiteConnectionDirectoryCache.start_scan", new_callable=AsyncMock, ): await manager.get_or_create_cache( @@ -1507,7 +613,7 @@ async def test_get_stats(self, manager: DirectoryCacheManager): """get_stats should return stats for all caches.""" with patch( - "app.services.directory_cache.ConnectionDirectoryCache.start_scan", + "app.services.directory_cache.SQLiteConnectionDirectoryCache.start_scan", new_callable=AsyncMock, ): cache = await manager.get_or_create_cache( @@ -1522,7 +628,7 @@ async def test_get_stats(self, manager: DirectoryCacheManager): stats = manager.get_stats() assert "conn-1" in stats assert stats["conn-1"]["state"] == CacheState.EMPTY.value - assert stats["conn-1"]["directory_count"] == 3 + assert stats["conn-1"]["directory_count"] == 0 assert stats["conn-1"]["last_scan_time"] is None assert stats["conn-1"]["scan_error"] is None @@ -1536,7 +642,7 @@ async def test_separate_caches_per_connection(self, manager: DirectoryCacheManag """Each connection should have its own independent cache.""" with patch( - "app.services.directory_cache.ConnectionDirectoryCache.start_scan", + "app.services.directory_cache.SQLiteConnectionDirectoryCache.start_scan", new_callable=AsyncMock, ): cache1 = await manager.get_or_create_cache( @@ -1557,6 +663,11 @@ async def test_separate_caches_per_connection(self, manager: DirectoryCacheManag cache1.add_directory("only_in_cache1") cache2.add_directory("only_in_cache2") + for cache in (cache1, cache2): + cache.index.open() + cache._opened.set() + cache._source_available.set() + cache.coordinator.apply_pending() assert cache1.search("only_in_cache1") == (["only_in_cache1"], 1) assert cache1.search("only_in_cache2") == ([], 0) assert cache2.search("only_in_cache2") == (["only_in_cache2"], 1) diff --git a/backend/tests/test_directory_cache_persistence.py b/backend/tests/test_directory_cache_persistence.py deleted file mode 100644 index 2c083d0a0..000000000 --- a/backend/tests/test_directory_cache_persistence.py +++ /dev/null @@ -1,1031 +0,0 @@ -""" -Tests for directory cache disk persistence (JSONL snapshot format). - -Tests cover: -- save_to_disk / load_from_disk round-trip -- Atomic write (temp file + rename) with .bak backup -- JSONL format validation (header + directory entries) -- Staleness detection (max age threshold, configurable in minutes) -- Version mismatch handling -- Connection ID mismatch handling -- Corrupt / empty file handling -- Backup fallback when main file is corrupt -- Count mismatch (still loads) -- Dirty flag tracking across mutations -- Coalesce flush loop -- Flush on stop -- Load-on-create in DirectoryCacheManager -- delete_persist_file (removes both .idx and .bak) -""" - -import asyncio -import json -import time -from pathlib import Path -from unittest.mock import AsyncMock, patch - -import pytest - -from app.services.directory_cache import ( - CACHE_BACKUP_EXTENSION, - CACHE_FILE_EXTENSION, - CACHE_FILE_VERSION, - CACHE_PERSIST_SUBDIR, - CacheState, - ConnectionDirectoryCache, - DirectoryCacheManager, -) - -# ============================================================================ -# Fixtures -# ============================================================================ - - -@pytest.fixture(name="persist_dir") -def persist_dir_fixture(tmp_path: Path) -> Path: - """Create a temporary persistence directory.""" - - d = tmp_path / CACHE_PERSIST_SUBDIR - d.mkdir(parents=True, exist_ok=True) - return d - - -@pytest.fixture(name="cache") -def cache_fixture(tmp_path: Path) -> ConnectionDirectoryCache: - """Create a cache with persistence pointed at a temp directory.""" - - with ( - patch("app.services.directory_cache.static") as mock_static, - patch("app.services.directory_cache.settings") as mock_settings, - ): - mock_static.data_dir = tmp_path - mock_settings.directory_cache_location = "" - mock_settings.directory_cache_coalesce_interval_seconds = 30 - mock_settings.directory_cache_max_staleness_minutes = 43200 - c = ConnectionDirectoryCache( - connection_id="persist-test-001", - host="server.local", - share_name="testshare", - username="testuser", - password="testpass", - port=445, - ) - - return c - - -@pytest.fixture(name="populated_cache") -def populated_cache_fixture(cache: ConnectionDirectoryCache) -> ConnectionDirectoryCache: - """Create a cache pre-populated with directory paths.""" - - dirs = [ - "documents", - "documents/work", - "documents/work/reports", - "photos", - "photos/vacation", - "music", - "music/rock", - "music/jazz", - "projects", - "projects/alpha", - ] - cache.add_directories(dirs) - return cache - - -@pytest.fixture(autouse=True) -def _mock_settings(): - """Ensure settings are available for all persistence operations.""" - - with patch("app.services.directory_cache.settings") as mock_settings: - mock_settings.directory_cache_location = "" - mock_settings.directory_cache_coalesce_interval_seconds = 30 - mock_settings.directory_cache_max_staleness_minutes = 43200 # 30 days - yield mock_settings - - -# ============================================================================ -# Helper: read JSONL file -# ============================================================================ - - -def read_jsonl(path: Path) -> tuple[dict, list[dict]]: - """Read a JSONL index file, returning (header, entries).""" - - with open(path, encoding="utf-8") as f: - header = json.loads(f.readline()) - entries = [json.loads(line) for line in f if line.strip()] - - return header, entries - - -# ============================================================================ -# save_to_disk / load_from_disk round-trip -# ============================================================================ - - -@pytest.mark.unit -class TestSaveLoadRoundTrip: - """Tests for save and load round-trip.""" - - def test_save_creates_file(self, populated_cache: ConnectionDirectoryCache): - """save_to_disk should create the JSONL index file.""" - - populated_cache.save_to_disk() - assert populated_cache._persist_path.exists() - - def test_round_trip_preserves_directories(self, populated_cache: ConnectionDirectoryCache, tmp_path: Path): - """Directories should survive a save/load round-trip.""" - - populated_cache.save_to_disk() - - # Create a fresh cache pointing at the same file - with patch("app.services.directory_cache.static") as mock_static: - mock_static.data_dir = tmp_path - fresh = ConnectionDirectoryCache( - connection_id="persist-test-001", - host="server.local", - share_name="testshare", - username="testuser", - password="testpass", - ) - - loaded = fresh.load_from_disk() - assert loaded is True - assert fresh.directory_count == populated_cache.directory_count - assert fresh.state == CacheState.READY - - # Verify all paths match - original_results, _ = populated_cache.search("", max_results=100) - loaded_results, _ = fresh.search("", max_results=100) - # search("") returns ([], 0) since query is empty — use internal set instead - with populated_cache._lock: - original_dirs = set(populated_cache._directories) - with fresh._lock: - loaded_dirs = set(fresh._directories) - assert original_dirs == loaded_dirs - - def test_save_sets_dirty_false(self, populated_cache: ConnectionDirectoryCache): - """save_to_disk should clear the dirty flag.""" - - assert populated_cache._dirty is True # mutations set dirty - populated_cache.save_to_disk() - assert populated_cache._dirty is False - - def test_load_sets_state_ready(self, populated_cache: ConnectionDirectoryCache, tmp_path: Path): - """load_from_disk should set state to READY.""" - - populated_cache.save_to_disk() - - with patch("app.services.directory_cache.static") as mock_static: - mock_static.data_dir = tmp_path - fresh = ConnectionDirectoryCache( - connection_id="persist-test-001", - host="server.local", - share_name="testshare", - username="testuser", - password="testpass", - ) - - assert fresh.state == CacheState.EMPTY - fresh.load_from_disk() - assert fresh.state == CacheState.READY - - -# ============================================================================ -# JSONL format validation -# ============================================================================ - - -@pytest.mark.unit -class TestJSONLFormat: - """Tests for the JSONL file format.""" - - def test_header_fields(self, populated_cache: ConnectionDirectoryCache): - """Header line should contain version, connection_id, timestamp, count.""" - - populated_cache.save_to_disk() - header, _ = read_jsonl(populated_cache._persist_path) - - assert header["v"] == CACHE_FILE_VERSION - assert header["connection_id"] == "persist-test-001" - assert header["source"] == populated_cache._source_identity - assert populated_cache.password not in json.dumps(header) - assert isinstance(header["ts"], float) - assert header["count"] == populated_cache.directory_count - - @pytest.mark.parametrize( - "field,value", - [ - ("host", "other-server"), - ("share_name", "other-share"), - ("username", "other-user"), - ("port", "1445"), - ("path_prefix", "/other-prefix"), - ("source_revision", "updated-connection"), - ], - ) - def test_retargeted_snapshot_is_rejected(self, populated_cache: ConnectionDirectoryCache, tmp_path: Path, field: str, value: str): - """A snapshot must belong to the current target, account and connection revision.""" - populated_cache.save_to_disk() - with patch("app.services.directory_cache.static") as mock_static: - mock_static.data_dir = tmp_path - fresh = ConnectionDirectoryCache( - connection_id=populated_cache.connection_id, - host=value if field == "host" else populated_cache.host, - share_name=value if field == "share_name" else populated_cache.share_name, - username=value if field == "username" else populated_cache.username, - password="replacement-password", - port=int(value) if field == "port" else populated_cache.port, - path_prefix=value if field == "path_prefix" else populated_cache._path_prefix, - source_revision=value if field == "source_revision" else "", - ) - - assert fresh.load_from_disk() is False - assert fresh.directory_count == 0 - - def test_legacy_snapshot_is_rejected(self, populated_cache: ConnectionDirectoryCache): - """Unbound version-one snapshots must trigger a new scan instead of being reused.""" - populated_cache.save_to_disk() - header, entries = read_jsonl(populated_cache._persist_path) - header["v"] = 1 - header.pop("source") - with populated_cache._persist_path.open("w", encoding="utf-8") as snapshot: - snapshot.write(json.dumps(header) + "\n") - for entry in entries: - snapshot.write(json.dumps(entry) + "\n") - - assert populated_cache.load_from_disk() is False - - def test_entry_format(self, populated_cache: ConnectionDirectoryCache): - """Each entry line should have a 'p' field with the directory path.""" - - populated_cache.save_to_disk() - _, entries = read_jsonl(populated_cache._persist_path) - - assert len(entries) == populated_cache.directory_count - paths = {e["p"] for e in entries} - assert "documents" in paths - assert "music/rock" in paths - - def test_compact_json(self, populated_cache: ConnectionDirectoryCache): - """JSONL should use compact separators (no spaces).""" - - populated_cache.save_to_disk() - with open(populated_cache._persist_path, encoding="utf-8") as f: - first_line = f.readline() - - # No spaces after colons or commas - assert ": " not in first_line - assert ", " not in first_line - - def test_file_extension(self, populated_cache: ConnectionDirectoryCache): - """Persist file should use the configured extension.""" - - assert populated_cache._persist_path.suffix == CACHE_FILE_EXTENSION - - def test_persist_subdirectory(self, populated_cache: ConnectionDirectoryCache, tmp_path: Path): - """Persist file should be under data_dir / CACHE_PERSIST_SUBDIR.""" - - populated_cache.save_to_disk() - assert populated_cache._persist_path.parent == tmp_path / CACHE_PERSIST_SUBDIR - - -# ============================================================================ -# Staleness detection -# ============================================================================ - - -@pytest.mark.unit -class TestStaleness: - """Tests for snapshot staleness handling.""" - - def test_stale_snapshot_rejected(self, populated_cache: ConnectionDirectoryCache, tmp_path: Path): - """Snapshots older than the configured max staleness should be rejected.""" - - populated_cache.save_to_disk() - - # Manually rewrite the header with an old timestamp (31 days past the 30-day limit) - _, entries = read_jsonl(populated_cache._persist_path) - max_staleness_seconds = 43200 * 60 # 30 days in seconds - old_ts = time.time() - max_staleness_seconds - 86400 # 1 day past staleness - header = { - "v": CACHE_FILE_VERSION, - "connection_id": "persist-test-001", - "source": populated_cache._source_identity, - "ts": old_ts, - "count": len(entries), - } - with open(populated_cache._persist_path, "w", encoding="utf-8") as f: - f.write(json.dumps(header) + "\n") - for e in entries: - f.write(json.dumps(e) + "\n") - - with patch("app.services.directory_cache.static") as mock_static: - mock_static.data_dir = tmp_path - fresh = ConnectionDirectoryCache( - connection_id="persist-test-001", - host="server.local", - share_name="testshare", - username="testuser", - password="testpass", - ) - - loaded = fresh.load_from_disk() - assert loaded is False - assert fresh.state == CacheState.EMPTY - - def test_fresh_snapshot_accepted(self, populated_cache: ConnectionDirectoryCache, tmp_path: Path): - """A recently written snapshot should be accepted.""" - - populated_cache.save_to_disk() - - with patch("app.services.directory_cache.static") as mock_static: - mock_static.data_dir = tmp_path - fresh = ConnectionDirectoryCache( - connection_id="persist-test-001", - host="server.local", - share_name="testshare", - username="testuser", - password="testpass", - ) - - loaded = fresh.load_from_disk() - assert loaded is True - assert fresh.directory_count == populated_cache.directory_count - - -# ============================================================================ -# Version mismatch -# ============================================================================ - - -@pytest.mark.unit -class TestVersionMismatch: - """Tests for version mismatch handling.""" - - def test_wrong_version_rejected(self, populated_cache: ConnectionDirectoryCache, tmp_path: Path): - """Snapshots with a different version should be rejected.""" - - populated_cache.save_to_disk() - - # Rewrite with wrong version - _, entries = read_jsonl(populated_cache._persist_path) - header = { - "v": 999, - "connection_id": "persist-test-001", - "ts": time.time(), - "count": len(entries), - } - with open(populated_cache._persist_path, "w", encoding="utf-8") as f: - f.write(json.dumps(header) + "\n") - for e in entries: - f.write(json.dumps(e) + "\n") - - with patch("app.services.directory_cache.static") as mock_static: - mock_static.data_dir = tmp_path - fresh = ConnectionDirectoryCache( - connection_id="persist-test-001", - host="server.local", - share_name="testshare", - username="testuser", - password="testpass", - ) - - loaded = fresh.load_from_disk() - assert loaded is False - - -# ============================================================================ -# Connection ID mismatch -# ============================================================================ - - -@pytest.mark.unit -class TestConnectionIdMismatch: - """Tests for connection ID mismatch handling.""" - - def test_wrong_connection_id_rejected(self, tmp_path: Path): - """Snapshots with a different connection_id should be rejected.""" - - persist_dir = tmp_path / CACHE_PERSIST_SUBDIR - persist_dir.mkdir(parents=True, exist_ok=True) - - # Write a file for a different connection - idx_path = persist_dir / f"persist-test-001{CACHE_FILE_EXTENSION}" - header = { - "v": CACHE_FILE_VERSION, - "connection_id": "different-conn-999", - "ts": time.time(), - "count": 1, - } - with open(idx_path, "w", encoding="utf-8") as f: - f.write(json.dumps(header) + "\n") - f.write(json.dumps({"p": "some/dir"}) + "\n") - - with patch("app.services.directory_cache.static") as mock_static: - mock_static.data_dir = tmp_path - cache = ConnectionDirectoryCache( - connection_id="persist-test-001", - host="server.local", - share_name="testshare", - username="testuser", - password="testpass", - ) - - loaded = cache.load_from_disk() - assert loaded is False - - -# ============================================================================ -# Corrupt / empty file handling -# ============================================================================ - - -@pytest.mark.unit -class TestCorruptFile: - """Tests for corrupt and edge-case file handling.""" - - def test_empty_file_rejected(self, cache: ConnectionDirectoryCache): - """An empty file should be rejected gracefully.""" - - cache._persist_path.parent.mkdir(parents=True, exist_ok=True) - cache._persist_path.write_text("") - - loaded = cache.load_from_disk() - assert loaded is False - - def test_invalid_json_header_rejected(self, cache: ConnectionDirectoryCache): - """A file with invalid JSON in the header should be rejected.""" - - cache._persist_path.parent.mkdir(parents=True, exist_ok=True) - cache._persist_path.write_text("this is not json\n") - - loaded = cache.load_from_disk() - assert loaded is False - - def test_invalid_json_entry_skipped(self, populated_cache: ConnectionDirectoryCache, tmp_path: Path): - """Invalid JSON in an entry line should not crash; partial load succeeds.""" - - populated_cache.save_to_disk() - - # Inject a corrupt line - with open(populated_cache._persist_path, "r", encoding="utf-8") as f: - lines = f.readlines() - - # Insert corrupt line after header - lines.insert(1, "NOT VALID JSON\n") - with open(populated_cache._persist_path, "w", encoding="utf-8") as f: - f.writelines(lines) - - with patch("app.services.directory_cache.static") as mock_static: - mock_static.data_dir = tmp_path - fresh = ConnectionDirectoryCache( - connection_id="persist-test-001", - host="server.local", - share_name="testshare", - username="testuser", - password="testpass", - ) - - # Should raise due to corrupt entry JSON - loaded = fresh.load_from_disk() - assert loaded is False - - def test_missing_file_returns_false(self, cache: ConnectionDirectoryCache): - """load_from_disk should return False if no file exists.""" - - loaded = cache.load_from_disk() - assert loaded is False - - -# ============================================================================ -# Count mismatch (still loads) -# ============================================================================ - - -@pytest.mark.unit -class TestCountMismatch: - """Tests for header count vs actual entry count mismatch.""" - - def test_count_mismatch_still_loads(self, populated_cache: ConnectionDirectoryCache, tmp_path: Path): - """If count doesn't match entries, cache should still load with warning.""" - - populated_cache.save_to_disk() - - # Rewrite header with wrong count - header, entries = read_jsonl(populated_cache._persist_path) - header["count"] = 999 # Wrong count - with open(populated_cache._persist_path, "w", encoding="utf-8") as f: - f.write(json.dumps(header) + "\n") - for e in entries: - f.write(json.dumps(e) + "\n") - - with patch("app.services.directory_cache.static") as mock_static: - mock_static.data_dir = tmp_path - fresh = ConnectionDirectoryCache( - connection_id="persist-test-001", - host="server.local", - share_name="testshare", - username="testuser", - password="testpass", - ) - - loaded = fresh.load_from_disk() - assert loaded is True - assert fresh.directory_count == len(entries) # Actual count, not header count - - -# ============================================================================ -# Dirty flag tracking -# ============================================================================ - - -@pytest.mark.unit -class TestDirtyFlag: - """Tests for dirty flag tracking across mutations.""" - - def test_initially_not_dirty(self, cache: ConnectionDirectoryCache): - """A fresh cache should not be dirty.""" - - assert cache._dirty is False - - def test_add_directory_sets_dirty(self, cache: ConnectionDirectoryCache): - """add_directory should set the dirty flag.""" - - cache.add_directory("test/dir") - assert cache._dirty is True - - def test_add_directories_sets_dirty(self, cache: ConnectionDirectoryCache): - """add_directories should set the dirty flag.""" - - cache.add_directories(["a", "b"]) - assert cache._dirty is True - - def test_remove_directory_sets_dirty(self, populated_cache: ConnectionDirectoryCache): - """remove_directory should set the dirty flag.""" - - populated_cache._dirty = False # Reset - populated_cache.remove_directory("documents") - assert populated_cache._dirty is True - - def test_rename_directory_sets_dirty(self, populated_cache: ConnectionDirectoryCache): - """rename_directory should set the dirty flag.""" - - populated_cache._dirty = False # Reset - populated_cache.rename_directory("music", "audio") - assert populated_cache._dirty is True - - def test_save_clears_dirty(self, populated_cache: ConnectionDirectoryCache): - """save_to_disk should clear the dirty flag.""" - - assert populated_cache._dirty is True - populated_cache.save_to_disk() - assert populated_cache._dirty is False - - -# ============================================================================ -# Flush on stop -# ============================================================================ - - -@pytest.mark.unit -class TestFlushOnStop: - """Tests for flushing dirty cache on stop.""" - - def test_stop_flushes_dirty_cache(self, populated_cache: ConnectionDirectoryCache): - """stop() should save to disk if cache is dirty and non-empty.""" - - assert populated_cache._dirty is True - assert populated_cache.directory_count > 0 - - populated_cache.stop() - - # File should have been written - assert populated_cache._persist_path.exists() - - def test_stop_skips_flush_if_clean(self, populated_cache: ConnectionDirectoryCache): - """stop() should not write if cache is not dirty.""" - - populated_cache.save_to_disk() # Clears dirty - populated_cache._persist_path.unlink() # Remove the file - - populated_cache.stop() - - # File should NOT have been recreated - assert not populated_cache._persist_path.exists() - - def test_stop_skips_flush_if_empty(self, cache: ConnectionDirectoryCache): - """stop() should not write if cache has no directories.""" - - cache._dirty = True # Simulate dirty but empty - - cache.stop() - - assert not cache._persist_path.exists() - - -# ============================================================================ -# delete_persist_file -# ============================================================================ - - -@pytest.mark.unit -class TestDeletePersistFile: - """Tests for delete_persist_file.""" - - def test_deletes_existing_file(self, populated_cache: ConnectionDirectoryCache): - """delete_persist_file should remove the index file.""" - - populated_cache.save_to_disk() - assert populated_cache._persist_path.exists() - - populated_cache.delete_persist_file() - assert not populated_cache._persist_path.exists() - - def test_deletes_backup_file_too(self, populated_cache: ConnectionDirectoryCache): - """delete_persist_file should also remove the .bak backup.""" - - populated_cache.save_to_disk() - # Save a second time to create both .idx and .bak - populated_cache._dirty = True - populated_cache.save_to_disk() - - backup_path = populated_cache._persist_path.with_suffix(CACHE_BACKUP_EXTENSION) - assert populated_cache._persist_path.exists() - assert backup_path.exists() - - populated_cache.delete_persist_file() - assert not populated_cache._persist_path.exists() - assert not backup_path.exists() - - def test_no_error_if_file_missing(self, cache: ConnectionDirectoryCache): - """delete_persist_file should not raise if no file exists.""" - - cache.delete_persist_file() # Should not raise - - -# ============================================================================ -# Backup file handling -# ============================================================================ - - -@pytest.mark.unit -class TestBackupFile: - """Tests for .bak backup file creation and fallback.""" - - def test_save_creates_backup(self, populated_cache: ConnectionDirectoryCache): - """Second save should create a .bak backup of the previous file.""" - - populated_cache.save_to_disk() - backup_path = populated_cache._persist_path.with_suffix(CACHE_BACKUP_EXTENSION) - assert not backup_path.exists() # First save — no previous file - - # Mutate and save again - populated_cache.add_directory("new/dir") - populated_cache.save_to_disk() - assert backup_path.exists() - - def test_backup_contains_previous_snapshot(self, populated_cache: ConnectionDirectoryCache): - """The .bak file should contain the data from the previous save.""" - - populated_cache.save_to_disk() - original_count = populated_cache.directory_count - - # Mutate and save again - populated_cache.add_directory("new/dir") - populated_cache.save_to_disk() - - backup_path = populated_cache._persist_path.with_suffix(CACHE_BACKUP_EXTENSION) - header, entries = read_jsonl(backup_path) - assert header["count"] == original_count - assert len(entries) == original_count - - def test_fallback_to_backup_on_corrupt_main(self, populated_cache: ConnectionDirectoryCache, tmp_path: Path): - """If the main file is corrupt, load_from_disk should fall back to backup.""" - - populated_cache.save_to_disk() - - # Save again to create .bak - populated_cache.add_directory("extra/dir") - populated_cache.save_to_disk() - - # Corrupt the main file - populated_cache._persist_path.write_text("CORRUPT DATA\n") - - with patch("app.services.directory_cache.static") as mock_static: - mock_static.data_dir = tmp_path - fresh = ConnectionDirectoryCache( - connection_id="persist-test-001", - host="server.local", - share_name="testshare", - username="testuser", - password="testpass", - ) - - loaded = fresh.load_from_disk() - assert loaded is True - # Should have the previous snapshot's count (without "extra/dir") - assert fresh.state == CacheState.READY - - def test_fallback_to_backup_on_missing_main(self, populated_cache: ConnectionDirectoryCache, tmp_path: Path): - """If the main file is missing but backup exists, should load from backup.""" - - populated_cache.save_to_disk() - - # Save again to create .bak - populated_cache.add_directory("extra/dir") - populated_cache.save_to_disk() - - # Remove the main file - populated_cache._persist_path.unlink() - - with patch("app.services.directory_cache.static") as mock_static: - mock_static.data_dir = tmp_path - fresh = ConnectionDirectoryCache( - connection_id="persist-test-001", - host="server.local", - share_name="testshare", - username="testuser", - password="testpass", - ) - - loaded = fresh.load_from_disk() - assert loaded is True - assert fresh.state == CacheState.READY - - -# ============================================================================ -# Config-driven settings -# ============================================================================ - - -@pytest.mark.unit -class TestConfigDrivenSettings: - """Tests for config-driven cache settings.""" - - def test_custom_location_absolute(self, tmp_path: Path): - """Custom absolute location should be used for persist path.""" - - custom_dir = tmp_path / "custom_cache" - with ( - patch("app.services.directory_cache.static") as mock_static, - patch("app.services.directory_cache.settings") as mock_settings, - ): - mock_static.data_dir = tmp_path - mock_settings.directory_cache_location = str(custom_dir) - mock_settings.directory_cache_coalesce_interval_seconds = 30 - mock_settings.directory_cache_max_staleness_minutes = 43200 - cache = ConnectionDirectoryCache( - connection_id="config-test-001", - host="server.local", - share_name="testshare", - username="testuser", - password="testpass", - ) - - assert cache._persist_path.parent == custom_dir - - def test_custom_location_relative(self, tmp_path: Path): - """Custom relative location should be resolved against data_dir.""" - - with ( - patch("app.services.directory_cache.static") as mock_static, - patch("app.services.directory_cache.settings") as mock_settings, - ): - mock_static.data_dir = tmp_path - mock_settings.directory_cache_location = "my_cache" - mock_settings.directory_cache_coalesce_interval_seconds = 30 - mock_settings.directory_cache_max_staleness_minutes = 43200 - cache = ConnectionDirectoryCache( - connection_id="config-test-002", - host="server.local", - share_name="testshare", - username="testuser", - password="testpass", - ) - - assert cache._persist_path.parent == tmp_path / "my_cache" - - def test_empty_location_uses_default(self, tmp_path: Path): - """Empty location should use default data_dir / CACHE_PERSIST_SUBDIR.""" - - with ( - patch("app.services.directory_cache.static") as mock_static, - patch("app.services.directory_cache.settings") as mock_settings, - ): - mock_static.data_dir = tmp_path - mock_settings.directory_cache_location = "" - mock_settings.directory_cache_coalesce_interval_seconds = 30 - mock_settings.directory_cache_max_staleness_minutes = 43200 - cache = ConnectionDirectoryCache( - connection_id="config-test-003", - host="server.local", - share_name="testshare", - username="testuser", - password="testpass", - ) - - assert cache._persist_path.parent == tmp_path / CACHE_PERSIST_SUBDIR - - def test_custom_staleness_is_respected(self, populated_cache: ConnectionDirectoryCache, tmp_path: Path, _mock_settings): - """Custom max_staleness_minutes should be used for staleness check.""" - - populated_cache.save_to_disk() - - # Set a very short staleness (1 minute) - _mock_settings.directory_cache_max_staleness_minutes = 1 - - # Rewrite header with timestamp 2 minutes old - _, entries = read_jsonl(populated_cache._persist_path) - old_ts = time.time() - 120 # 2 minutes old - header = { - "v": CACHE_FILE_VERSION, - "connection_id": "persist-test-001", - "source": populated_cache._source_identity, - "ts": old_ts, - "count": len(entries), - } - with open(populated_cache._persist_path, "w", encoding="utf-8") as f: - f.write(json.dumps(header) + "\n") - for e in entries: - f.write(json.dumps(e) + "\n") - - with patch("app.services.directory_cache.static") as mock_static: - mock_static.data_dir = tmp_path - fresh = ConnectionDirectoryCache( - connection_id="persist-test-001", - host="server.local", - share_name="testshare", - username="testuser", - password="testpass", - ) - - loaded = fresh.load_from_disk() - assert loaded is False # 2 min old > 1 min limit - - -# ============================================================================ -# Persistence constants -# ============================================================================ - - -@pytest.mark.unit -class TestPersistenceConstants: - """Verify persistence constants have sensible values.""" - - def test_file_version_positive(self): - """File version should be a positive integer.""" - - assert CACHE_FILE_VERSION >= 1 - - def test_file_extension_starts_with_dot(self): - """File extension should start with a dot.""" - - assert CACHE_FILE_EXTENSION.startswith(".") - - def test_backup_extension_starts_with_dot(self): - """Backup extension should start with a dot.""" - - assert CACHE_BACKUP_EXTENSION.startswith(".") - - def test_backup_extension_differs_from_main(self): - """Backup extension should differ from the main extension.""" - - assert CACHE_BACKUP_EXTENSION != CACHE_FILE_EXTENSION - - def test_persist_subdir_non_empty(self): - """Persist subdirectory name should not be empty.""" - - assert len(CACHE_PERSIST_SUBDIR) > 0 - - -# ============================================================================ -# Manager: load-on-create -# ============================================================================ - - -@pytest.mark.asyncio -@pytest.mark.unit -class TestManagerLoadOnCreate: - """Tests for DirectoryCacheManager.get_or_create_cache loading from disk.""" - - async def test_loaded_snapshot_keeps_both_scan_tasks_cancellable(self): - """Snapshot verification must not replace the periodic task's tracked handle.""" - manager = DirectoryCacheManager() - verification_started = asyncio.Event() - periodic_started = asyncio.Event() - blocked = asyncio.Event() - - async def verification(): - verification_started.set() - await blocked.wait() - - async def periodic(): - periodic_started.set() - await blocked.wait() - - with ( - patch.object(ConnectionDirectoryCache, "load_from_disk", return_value=True), - patch.object(ConnectionDirectoryCache, "_start_watcher"), - patch.object(ConnectionDirectoryCache, "_start_coalesce_flush"), - patch.object(ConnectionDirectoryCache, "_run_rescan", side_effect=verification), - patch.object(ConnectionDirectoryCache, "_periodic_rescan_loop", side_effect=periodic), - ): - cache = await manager.get_or_create_cache("task-test", "host", "share", "user", "password") - await verification_started.wait() - await periodic_started.wait() - verification_task = cache._verification_task - periodic_task = cache._rescan_task - assert verification_task is not None - assert periodic_task is not None - assert verification_task is not periodic_task - - cache._request_stop() - await asyncio.gather(verification_task, periodic_task, return_exceptions=True) - - assert verification_task.cancelled() - assert periodic_task.cancelled() - - async def test_loads_snapshot_instead_of_scanning(self, tmp_path: Path): - """When a valid snapshot exists, manager should load it and skip initial scan.""" - - persist_dir = tmp_path / CACHE_PERSIST_SUBDIR - persist_dir.mkdir(parents=True, exist_ok=True) - - conn_id = "mgr-test-001" - - # Write a valid snapshot - idx_path = persist_dir / f"{conn_id}{CACHE_FILE_EXTENSION}" - header = { - "v": CACHE_FILE_VERSION, - "connection_id": conn_id, - "source": { - "host": "server.local", - "share_name": "testshare", - "port": 445, - "path_prefix": "", - "username": "testuser", - "revision": "", - }, - "ts": time.time(), - "count": 3, - } - with open(idx_path, "w", encoding="utf-8") as f: - f.write(json.dumps(header) + "\n") - f.write(json.dumps({"p": "dir_a"}) + "\n") - f.write(json.dumps({"p": "dir_b"}) + "\n") - f.write(json.dumps({"p": "dir_c"}) + "\n") - - manager = DirectoryCacheManager() - - with patch("app.services.directory_cache.static") as mock_static: - mock_static.data_dir = tmp_path - - # Patch start_scan to verify it's NOT called, and _run_rescan to be a no-op - with ( - patch.object(ConnectionDirectoryCache, "start_scan", new_callable=AsyncMock) as mock_scan, - patch.object(ConnectionDirectoryCache, "_run_rescan", new_callable=AsyncMock), - patch.object(ConnectionDirectoryCache, "_start_watcher"), - patch.object(ConnectionDirectoryCache, "_start_periodic_rescan"), - patch.object(ConnectionDirectoryCache, "_start_coalesce_flush"), - ): - cache = await manager.get_or_create_cache( - connection_id=conn_id, - host="server.local", - share_name="testshare", - username="testuser", - password="testpass", - ) - - # start_scan should NOT have been called (snapshot was loaded) - mock_scan.assert_not_called() - - assert cache.state == CacheState.READY - assert cache.directory_count == 3 - assert cache.search("dir_a") == (["dir_a"], 1) - - # Clean up - cache._stop_event.set() - - async def test_falls_back_to_scan_without_snapshot(self, tmp_path: Path): - """Without a snapshot, manager should start a full scan.""" - - manager = DirectoryCacheManager() - - with patch("app.services.directory_cache.static") as mock_static: - mock_static.data_dir = tmp_path - - with patch.object(ConnectionDirectoryCache, "start_scan", new_callable=AsyncMock) as mock_scan: - cache = await manager.get_or_create_cache( - connection_id="mgr-test-002", - host="server.local", - share_name="testshare", - username="testuser", - password="testpass", - ) - - mock_scan.assert_called_once() - - cache._stop_event.set() diff --git a/backend/tests/test_directory_index_contract.py b/backend/tests/test_directory_index_contract.py new file mode 100644 index 000000000..af706982e --- /dev/null +++ b/backend/tests/test_directory_index_contract.py @@ -0,0 +1,6003 @@ +"""Language-neutral SQLite contract and transaction checks.""" + +import asyncio +import json +import os +import sqlite3 +import stat +import threading +import time +import unicodedata +from concurrent.futures import ThreadPoolExecutor +from contextlib import closing, contextmanager +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +from smbprotocol.exceptions import SMBResponseException +from smbprotocol.header import NtStatus, SMB2HeaderResponse + +from app.services.directory_index import MAX_QUERY_BYTES, MAX_QUERY_TERMS, DirectoryIndex, normalize_search_text, search_terms + +CONTRACT_ROOT = Path(__file__).resolve().parents[2] / "shared" / "directory-index" + + +def _smb_response_error(status: int) -> SMBResponseException: + """Build the registered SMB error subtype from an explicit response status.""" + header = SMB2HeaderResponse() + header["status"] = status + return SMBResponseException(header) + + +def test_standard_sqlite_runtime_has_required_features(): + from app.services.directory_index import validate_sqlite_runtime + + validate_sqlite_runtime() + + +def test_candidate_progress_survives_an_interrupted_scan(index): + index.write_paths(["accepted"]) + assert index.finish_generation(1) + candidate, selected = index.prepare_scan() + assert not selected + index.seed_scan(candidate) + index.write_scan_children(candidate, [("retained", True)]) + index.finish_scan_parent(candidate, "") + before = index.search("accepted retained") + assert index.has_resumable_scan() + assert index.prepare_scan() == (candidate, False) + assert index.pending_scan_paths(candidate) == ["retained"] + assert index.search("accepted retained") == before + + +def test_completed_scan_revalidation_is_bounded_and_preserves_rows(index): + from app.services.directory_index import ROW_BATCH_SIZE + + index.write_paths(["accepted"]) + assert index.finish_generation(1) + candidate, selected = index.prepare_scan() + assert not selected + index.seed_scan(candidate) + paths = [f"child-{number}" for number in range(ROW_BATCH_SIZE + 1)] + for offset in range(0, len(paths), ROW_BATCH_SIZE): + index.write_scan_children(candidate, [(path, True) for path in paths[offset : offset + ROW_BATCH_SIZE]]) + for path in ["", *paths]: + index.finish_scan_parent(candidate, path) + before = index.search("accepted") + assert index.requeue_completed_scan_batch(candidate) + assert len(index.pending_scan_paths(candidate)) == ROW_BATCH_SIZE + assert not index.requeue_completed_scan_batch(candidate) + assert ( + index._connection().execute("SELECT count(*) FROM scan_queue WHERE generation_id = ? AND processed = 0", (candidate,)).fetchone()[0] + == len(paths) + 1 + ) + assert not index.requeue_completed_scan_batch(candidate) + with pytest.raises(ValueError, match="no longer active"): + index.requeue_completed_scan_batch(1) + assert index.search("accepted") == before + + +@pytest.mark.asyncio +async def test_automatic_full_pass_spacing_cannot_be_bypassed_by_requests(sqlite_cache, monkeypatch): + from app.core.directory_index_contract import DIRECTORY_INDEX_CONTRACT + + cache = sqlite_cache + cache.index.write_paths(["retained"]) + assert cache.index.finish_generation(1) + cache._source_available.set() + cache._next_full_pass_at = time.monotonic() + DIRECTORY_INDEX_CONTRACT.limits["minimum_full_pass_spacing_seconds"] + pool = AsyncMock() + monkeypatch.setattr("app.services.directory_cache.get_connection_pool", pool) + for _attempt in range(10): + await cache._run_rescan() + pool.assert_not_awaited() + assert cache.index.search("retained").directory_count == 1 + + +def test_reopened_backend_storage_retains_rows_but_not_verified_coverage(sqlite_cache): + cache = sqlite_cache + cache.index.write_paths(["retained"]) + assert cache.index.finish_generation(1) + before = cache.index.search("retained") + cache.index.close() + cache._initialize_storage() + after = cache.index.search("retained") + assert after.results == before.results + assert after.coverage == "uncertain" + assert cache.coordinator.verification_required + + +def test_directory_storage_events_do_not_expose_paths_or_sql_error_text(index, caplog): + import logging + + from app.services.directory_index import transaction + + caplog.set_level(logging.DEBUG, logger="app.services.directory_index") + index.close() + index.open() + index.write_paths(["private-directory-term"]) + assert index.finish_generation(1) + before = index.search("private-directory-term") + index.checkpoint() + with pytest.raises(sqlite3.DatabaseError): + with transaction(index._connection()) as writer: + writer.execute("SELECT * FROM private_sql_error_text") + assert index.search("private-directory-term") == before + events = {getattr(record, "event", None) for record in caplog.records} + assert { + "directory_storage_initialized", + "directory_transaction_failed", + "directory_generation_accepted", + "directory_checkpoint_completed", + } <= events + assert "directory_transaction_completed" not in events + payload = " ".join(str(record.__dict__) for record in caplog.records) + assert str(index.path) not in payload + assert "private-directory-term" not in payload + assert "private_sql_error_text" not in payload + + +def test_idle_directory_transactions_are_quiet_but_slow_work_is_reported(index, caplog, monkeypatch): + import logging + + from app.services.directory_index import transaction + + connection = index._connection() + caplog.set_level(logging.DEBUG, logger="app.services.directory_index") + for _ in range(10): + with transaction(connection) as reader: + reader.execute("SELECT 1").fetchall() + assert not caplog.records + clock = iter((0.0, 2.0)) + monkeypatch.setattr("app.services.directory_index.time.monotonic", lambda: next(clock)) + with transaction(connection) as reader: + reader.execute("SELECT 1").fetchall() + assert len(caplog.records) == 1 + assert caplog.records[0].event == "directory_transaction_slow" + assert caplog.records[0].duration_ms == 2000 + + +def test_directory_query_connection_is_read_only(index): + assert isinstance(index._connection(), sqlite3.Connection) + with closing(index._connect(readonly=True)) as reader: + with pytest.raises(sqlite3.OperationalError, match="readonly"): + reader.execute("UPDATE source_metadata SET accepted_revision = accepted_revision + 1") + assert index.status().accepted_revision == 0 + + +def test_directory_read_transaction_keeps_metadata_and_rows_consistent(index): + from app.services.directory_index import transaction + + index.write_paths(["accepted"]) + with closing(index._connect(readonly=True)) as reader, transaction(reader): + before = reader.execute("SELECT accepted_revision FROM source_metadata").fetchone() + index.write_paths(["new"]) + assert reader.execute("SELECT accepted_revision FROM source_metadata").fetchone() == before + assert reader.execute("SELECT path FROM directories ORDER BY path").fetchall() == [("accepted",)] + assert index.search("accepted new").total_matches == 2 + assert index.status().accepted_revision == before[0] + 1 + + +def test_directory_checkpoint_uses_writer_connection_and_lock(index): + entered = threading.Event() + statements = [] + writer = index._connection() + writer.set_trace_callback(statements.append) + + def checkpoint(): + entered.set() + index.checkpoint() + + try: + with ThreadPoolExecutor(max_workers=1) as executor: + with index._writer_lock: + pending = executor.submit(checkpoint) + assert entered.wait(timeout=5) + with pytest.raises(TimeoutError): + pending.result(timeout=0.05) + assert not statements + pending.result(timeout=5) + assert statements == ["PRAGMA wal_checkpoint(PASSIVE)"] + finally: + writer.set_trace_callback(None) + + +def test_inactive_cleanup_defers_deletion_for_pinned_reader(index): + from app.services.directory_index import transaction + from app.services.directory_index_resources import DirectoryIndexResources + + resources = DirectoryIndexResources(index.path.parent) + index.write_paths(["accepted"]) + with closing(index._connect(readonly=True)) as reader, transaction(reader): + accessed = reader.execute("SELECT last_access_at_ms FROM source_metadata").fetchone()[0] + index.write_paths(["new"]) + index.close() + assert not resources._delete_inactive(index.path, accessed) + assert index.path.is_file() + assert reader.execute("SELECT path FROM directories ORDER BY path").fetchall() == [("accepted",)] + assert resources._delete_inactive(index.path, accessed) + assert not index.path.exists() + + +def test_shared_resource_contract_defaults_and_bounds(): + from app.core.directory_index_contract import DIRECTORY_INDEX_CONTRACT + + assert DIRECTORY_INDEX_CONTRACT.limits["row_batch_size"] == 256 + assert DIRECTORY_INDEX_CONTRACT.policy["disk_budget_bytes"].default == 2 * 1024 * 1024 * 1024 + assert DIRECTORY_INDEX_CONTRACT.policy["inactive_retention_seconds"].default == 7 * 24 * 60 * 60 + assert DIRECTORY_INDEX_CONTRACT.policy["max_retained_inactive_sources"].default == 4 + for field in DIRECTORY_INDEX_CONTRACT.policy.values(): + assert field.validate(field.default) == field.default + with pytest.raises(ValueError): + field.validate(0) + with pytest.raises(ValueError): + field.validate(True) + + +def test_directory_disk_accounting_includes_wal_work_and_legacy_files(tmp_path): + from app.services.directory_index_resources import DirectoryIndexResources + + root = tmp_path / "cache" + namespace = root / "sqlite" + namespace.mkdir(parents=True) + for name in ("source.sqlite3", "source.sqlite3-wal", "source.sqlite3-shm", "source.sqlite3-journal", "temporary.work"): + (namespace / name).write_bytes(b"1234") + (root / "legacy.idx").write_bytes(b"123") + (root / "legacy.bak").write_bytes(b"12") + resources = DirectoryIndexResources(namespace) + assert resources.usage().managed_bytes == 25 + + +def test_legacy_retirement_is_bounded_and_preserves_recent_writer_work(tmp_path): + from uuid import UUID + + from app.services.directory_index_resources import LEASE_CLEANUP_BATCH, DirectoryIndexResources + + root = tmp_path / "cache" + namespace = root / "sqlite" + namespace.mkdir(parents=True, mode=0o700) + resources = DirectoryIndexResources(namespace) + snapshots = [root / f"{UUID(int=number + 1)}.idx" for number in range(LEASE_CLEANUP_BATCH + 1)] + for path in snapshots: + path.write_bytes(b"obsolete snapshot; never import") + backup = root / f"{UUID(int=1)}.bak" + backup.write_bytes(b"obsolete backup") + recent = root / ".1234abcd_abcdefgh.tmp" + stale = root / ".1234abcd_ijklmnop.tmp" + unrelated = root / "unrelated.idx" + for path in (recent, stale, unrelated): + path.write_bytes(b"preserve unless eligible") + now = int(time.time() * 1000) + expired = now / 1000 - resources.policy.inactive_retention_seconds - 1 + os.utime(stale, (expired, expired)) + assert resources.cleanup_inactive(now_ms=now) == LEASE_CLEANUP_BATCH + assert resources._next_cleanup_at == 0 + assert recent.is_file() and unrelated.is_file() + assert resources.cleanup_inactive(now_ms=now) == 3 + assert not any(path.exists() for path in (*snapshots, backup, stale)) + assert recent.read_bytes() == unrelated.read_bytes() == b"preserve unless eligible" + + +def test_older_snapshot_writer_cannot_change_active_sqlite_results(sqlite_cache): + from uuid import UUID + + cache = sqlite_cache + cache.index.write_paths(["accepted/child"]) + assert cache.index.finish_generation(1) + before = cache.index.search("accepted") + legacy = cache._persist_path.parent.parent / f"{UUID(int=1)}.idx" + for _ in range(2): + legacy.write_bytes(b'old writer data: {"p":"unverified"}\n') + assert cache.resources.cleanup_legacy() == 1 + assert not legacy.exists() + assert cache.index.search("accepted") == before + assert cache.index.search("unverified").total_matches == 0 + assert cache.index.path.is_file() + + +def test_legacy_cleanup_does_not_block_private_namespace_under_sticky_parent(tmp_path): + from uuid import UUID + + from app.services.directory_index_resources import DirectoryIndexResources + + root = tmp_path / "shared" + root.mkdir() + root.chmod(0o1777) + namespace = root / "private" + namespace.mkdir(mode=0o700) + legacy = root / f"{UUID(int=1)}.idx" + legacy.write_bytes(b"not owned by the private namespace") + resources = DirectoryIndexResources(namespace) + with resources.reserve_growth(): + assert legacy.is_file() + assert resources.usage().reserved_bytes == 0 + + +def test_legacy_cleanup_can_retry_after_unlink_failure(sqlite_cache, monkeypatch): + from uuid import UUID + + cache = sqlite_cache + cache.index.write_paths(["accepted"]) + assert cache.index.finish_generation(1) + before = cache.index.search("accepted") + legacy = cache._persist_path.parent.parent / f"{UUID(int=1)}.idx" + legacy.write_bytes(b"obsolete") + original = os.unlink + + def denied(name, **kwargs): + if name == legacy.name: + raise PermissionError("private path must not enter query status") + return original(name, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr("app.services.directory_index_resources.os.unlink", denied) + assert cache.resources.cleanup_legacy() == 0 + assert legacy.is_file() + assert cache.index.search("accepted") == before + assert cache.resources.cleanup_legacy() == 1 + assert cache.index.search("accepted") == before + + +@pytest.mark.parametrize("kind", ["link", "hard_link", "directory", "foreign_owner"]) +def test_legacy_retirement_preserves_unsafe_or_unowned_entries(tmp_path, monkeypatch, kind): + from uuid import UUID + + from app.services.directory_index_resources import DirectoryIndexResources + + namespace = tmp_path / "sqlite" + namespace.mkdir(mode=0o700) + legacy = tmp_path / f"{UUID(int=1)}.idx" + target = tmp_path / "unrelated" + target.write_bytes(b"must survive") + if kind == "link": + legacy.symlink_to(target) + elif kind == "hard_link": + os.link(target, legacy) + elif kind == "directory": + legacy.mkdir() + else: + legacy.write_bytes(b"foreign") + original = os.scandir + + @contextmanager + def entries(descriptor): + with original(descriptor) as iterator: + yield ( + SimpleNamespace( + name=entry.name, + stat=lambda **_kwargs: SimpleNamespace(st_mode=stat.S_IFREG, st_uid=os.getuid() + 1, st_nlink=1), + ) + for entry in iterator + ) + + monkeypatch.setattr("app.services.directory_index_resources.os.scandir", entries) + assert DirectoryIndexResources(namespace).cleanup_legacy() == 0 + assert legacy.exists() + assert target.read_bytes() == b"must survive" + + +@pytest.mark.skipif(os.name != "posix", reason="Unix hard-link qualification") +@pytest.mark.parametrize("suffix", ["", ".lease", "-wal", "-shm"]) +def test_hard_linked_database_and_sidecars_are_rejected(index, suffix): + alias = index.path.parent / "alias.data" + os.link(Path(str(index.path) + suffix), alias) + other = DirectoryIndex(index.path, source_kind=index.source_kind, source_identity=json.loads(index.source_identity)) + try: + with pytest.raises(PermissionError, match="hard links"): + other.open() + with pytest.raises(PermissionError, match="hard links"): + index.search("") + finally: + alias.unlink() + assert index.search("").directory_count == 0 + + +@pytest.mark.skipif(os.name != "posix", reason="Unix database-open ownership policy") +@pytest.mark.parametrize("change", ["alias", "database_permissions", "namespace_permissions"]) +def test_database_open_revalidates_private_storage_after_connect(index, monkeypatch, change): + from app.services.directory_index import connect_directory_database + + index.write_paths(["accepted"]) + before = index.search("accepted") + database_mode = stat.S_IMODE(index.path.stat().st_mode) + namespace_mode = stat.S_IMODE(index.path.parent.stat().st_mode) + alias = index.path.parent / "alias.data" + connections = [] + original = sqlite3.connect + + def connect(*args, **kwargs): + connection = original(*args, **kwargs) + connections.append(connection) + if change == "alias": + os.link(index.path, alias) + elif change == "database_permissions": + index.path.chmod(database_mode | stat.S_IWGRP) + else: + index.path.parent.chmod(namespace_mode | stat.S_IRGRP) + return connection + + try: + with monkeypatch.context() as patch: + patch.setattr(sqlite3, "connect", connect) + with pytest.raises(PermissionError): + connect_directory_database(index.path, readonly=True) + assert len(connections) == 1 + with pytest.raises(sqlite3.ProgrammingError, match="closed"): + connections[0].execute("SELECT 1") + finally: + for connection in connections: + connection.close() + alias.unlink(missing_ok=True) + index.path.chmod(database_mode) + index.path.parent.chmod(namespace_mode) + assert index.search("accepted") == before + + +@pytest.mark.parametrize( + "corruption", + [ + "UPDATE generations SET indexed_count = 1 WHERE id = 1", + "UPDATE generations SET issue_count = 1 WHERE id = 1", + "UPDATE generations SET state = 'candidate' WHERE id = 1", + "UPDATE source_metadata SET selected_generation = NULL WHERE id = 1", + ], +) +def test_startup_rejects_inconsistent_counts_and_unpublished_selection(index, corruption): + index._connection().execute(corruption) + index.close() + with pytest.raises(ValueError, match="generation metadata"): + index.open() + + +@pytest.mark.skipif(os.name != "posix", reason="Unix directory ownership policy") +@pytest.mark.parametrize("mode", [0o777, 0o775]) +def test_namespace_lease_rejects_replaceable_ancestors_before_creation(tmp_path, mode): + parent = tmp_path / "replaceable" + parent.mkdir(mode=mode) + parent.chmod(mode) + namespace = parent / "private" + with pytest.raises(PermissionError, match="unsafe ancestor"): + DirectoryIndex.acquire_namespace_lease(namespace) + assert not namespace.exists() + + +@pytest.mark.skipif(os.name != "posix", reason="Unix directory ownership policy") +def test_namespace_lease_accepts_trusted_sticky_ancestors(tmp_path): + parent = tmp_path / "sticky" + parent.mkdir() + parent.chmod(0o1777) + descriptor = DirectoryIndex.acquire_namespace_lease(parent / "private") + os.close(descriptor) + + +@pytest.mark.skipif(os.name != "posix", reason="Unix directory ownership policy") +def test_namespace_lease_rejects_foreign_owned_ancestors(tmp_path, monkeypatch): + parent = tmp_path / "foreign" + parent.mkdir() + original = Path.lstat + + def metadata(path, *args, **kwargs): + information = original(path, *args, **kwargs) + if path == parent: + return SimpleNamespace(st_mode=information.st_mode, st_uid=os.getuid() + 1) + return information + + monkeypatch.setattr(Path, "lstat", metadata) + with pytest.raises(PermissionError, match="unsafe ancestor"): + DirectoryIndex.acquire_namespace_lease(parent / "private") + assert not (parent / "private").exists() + + +@pytest.mark.skipif(os.name != "posix", reason="Unix directory ownership policy") +def test_readonly_queries_recheck_private_namespace_permissions(index): + namespace = index.path.parent + original_mode = namespace.stat().st_mode & 0o777 + index.write_paths(["accepted"]) + before = index.search("accepted") + namespace.chmod(0o755) + try: + with pytest.raises(PermissionError, match="0700"): + index.search("accepted") + finally: + namespace.chmod(original_mode) + assert index.search("accepted") == before + + +@pytest.mark.parametrize("incompatible", ["schema", "normalization", "traversal"]) +def test_rebuildable_database_validation_has_a_distinct_error_and_releases_ownership(index, incompatible): + from app.services.directory_index import SCHEMA_VERSION, DirectoryIndexRebuildRequired + + index._connection().execute("PRAGMA ignore_check_constraints = ON") + if incompatible == "schema": + index._connection().execute(f"PRAGMA user_version = {SCHEMA_VERSION + 1}") + elif incompatible == "normalization": + index._connection().execute("UPDATE source_metadata SET normalization_version = normalization_version + 1") + else: + index._connection().execute("UPDATE source_metadata SET traversal_version = traversal_version + 1") + index._connection().execute("PRAGMA ignore_check_constraints = OFF") + index.close() + with pytest.raises(DirectoryIndexRebuildRequired): + index.open() + assert index._writer is None + assert index._lease_fd is None + + +def test_wrong_source_database_is_not_classified_for_automatic_replacement(index): + from app.services.directory_index import DirectoryIndexRebuildRequired + + index._connection().execute("UPDATE source_metadata SET source_identity = ?", ('{"different":true}',)) + index.close() + with pytest.raises(ValueError) as caught: + index.open() + assert not isinstance(caught.value, DirectoryIndexRebuildRequired) + assert index._writer is None + assert index._lease_fd is None + + +def test_invalid_database_quarantine_keeps_source_ownership_and_bounds_retention(index): + from app.services.directory_index import QUARANTINE_FILE_NAME + + index.write_paths(["accepted"]) + index.close() + original = index.path.read_bytes() + rejected = index.path.parent / QUARANTINE_FILE_NAME + index._acquire_storage() + try: + index._quarantine_owned() + assert not index.path.exists() + assert rejected.read_bytes() == original + assert index._lease_fd is not None + with pytest.raises(BlockingIOError): + DirectoryIndex.acquire_storage_lease(index.path) + finally: + index._release_storage() + assert DirectoryIndex.discard_quarantine(index.path.parent) + assert not DirectoryIndex.discard_quarantine(index.path.parent) + assert not rejected.exists() + + +@pytest.mark.skipif(os.name != "posix", reason="Descriptor-relative cleanup requires POSIX.") +@pytest.mark.parametrize("replacement", ["link", "directory"]) +def test_quarantine_cleanup_refuses_replaced_namespace(index, monkeypatch, replacement): + from app.services.directory_index import QUARANTINE_FILE_NAME + + index.write_paths(["retained"]) + index.close() + namespace = index.path.parent + original = namespace.with_name(f"{namespace.name}-original") + unrelated = namespace.with_name(f"{namespace.name}-unrelated") + unrelated.mkdir(mode=0o700) + rejected = namespace / QUARANTINE_FILE_NAME + rejected.write_bytes(b"rejected data") + victim = unrelated / QUARANTINE_FILE_NAME + victim.write_bytes(b"unrelated data") + validate = DirectoryIndex._validate_storage_files + + def replace_after_validation(path): + validate(path) + if path == rejected: + namespace.rename(original) + if replacement == "link": + namespace.symlink_to(unrelated, target_is_directory=True) + else: + unrelated.rename(namespace) + + with monkeypatch.context() as patch: + patch.setattr(DirectoryIndex, "_validate_storage_files", replace_after_validation) + try: + with pytest.raises(PermissionError): + DirectoryIndex._clear_quarantine_owned(rejected) + assert (original / QUARANTINE_FILE_NAME).read_bytes() == b"rejected data" + assert rejected.read_bytes() == b"unrelated data" + finally: + if replacement == "link": + namespace.unlink() + else: + namespace.rename(unrelated) + original.rename(namespace) + assert victim.read_bytes() == b"unrelated data" + index.open() + assert index.search("retained").total_matches == 1 + + +def test_quarantine_move_failure_restores_original_sidecars_and_preserves_error(index, monkeypatch): + import app.services.directory_index as directory_index + + index.close() + sidecar = Path(str(index.path) + "-journal") + sidecar.write_bytes(b"rejected journal") + original = index.path.read_bytes() + journal = sidecar.read_bytes() + replace = os.replace + failure = PermissionError("Injected main-file move refusal.") + + def move(source, target, **kwargs): + if source in (index.path, index.path.name): + raise failure + return replace(source, target, **kwargs) + + monkeypatch.setattr(directory_index.os, "replace", move) + index._acquire_storage() + try: + with pytest.raises(PermissionError) as caught: + index._quarantine_owned() + assert caught.value is failure + assert index.path.read_bytes() == original + assert sidecar.read_bytes() == journal + assert index._lease_fd is not None + finally: + index._release_storage() + sidecar.unlink() + + +@pytest.mark.parametrize("invalid", ["schema", "not_database", "generation", "metadata", "normalization", "traversal", "scan_queue"]) +def test_invalid_directory_database_rebuilds_once_without_serving_rejected_rows(index, monkeypatch, invalid): + from app.services.directory_index import QUARANTINE_FILE_NAME, SCHEMA_VERSION + + index.write_paths(["rejected"]) + assert index.finish_generation(1) + if invalid == "schema": + index._connection().execute(f"PRAGMA user_version = {SCHEMA_VERSION + 1}") + elif invalid == "generation": + index._connection().execute("UPDATE generations SET indexed_count = indexed_count + 1") + elif invalid == "metadata": + index._connection().execute("DELETE FROM source_metadata") + elif invalid == "scan_queue": + index._connection().execute("DROP TABLE scan_queue") + elif invalid in {"normalization", "traversal"}: + index._connection().execute("PRAGMA ignore_check_constraints = ON") + column = "normalization_version" if invalid == "normalization" else "traversal_version" + index._connection().execute(f"UPDATE source_metadata SET {column} = {column} + 1") + index._connection().execute("PRAGMA ignore_check_constraints = OFF") + index.close() + if invalid == "not_database": + index.path.write_bytes(b"not a SQLite directory database") + rejected = index.path.read_bytes() + original = index._open_owned + + def open_while_owned(): + assert index._lease_fd is not None + with pytest.raises(BlockingIOError): + DirectoryIndex.acquire_storage_lease(index.path) + original() + + monkeypatch.setattr(index, "_open_owned", open_while_owned) + index.open(rebuild_invalid=True) + assert index.search("rejected").total_matches == 0 + assert index.status().coverage == "partial" + assert index.status().last_verified_at_ms is None + assert index._recovery_attempted + assert (index.path.parent / QUARANTINE_FILE_NAME).read_bytes() == rejected + assert index._lease_fd is not None + index.write_paths(["fresh"]) + assert index.finish_generation(1) + assert index.search("fresh").total_matches == 1 + + +@pytest.mark.parametrize("wrong", ["application", "source"]) +def test_automatic_recovery_refuses_unrelated_or_wrong_source_files(index, wrong): + from app.services.directory_index import APPLICATION_ID, QUARANTINE_FILE_NAME + + if wrong == "application": + index._connection().execute(f"PRAGMA application_id = {APPLICATION_ID + 1}") + else: + index._connection().execute("UPDATE source_metadata SET source_identity = ?", ('{"unrelated":true}',)) + index.close() + before = index.path.read_bytes() + with pytest.raises(ValueError): + index.open(rebuild_invalid=True) + assert index.path.read_bytes() == before + assert not (index.path.parent / QUARANTINE_FILE_NAME).exists() + assert not index._recovery_attempted + assert index._lease_fd is None + + +@pytest.mark.parametrize("code", [sqlite3.SQLITE_FULL, sqlite3.SQLITE_IOERR, sqlite3.SQLITE_READONLY, None]) +def test_recovery_never_discards_data_after_storage_refusal(index, monkeypatch, code): + from app.services.directory_index import QUARANTINE_FILE_NAME + + index.close() + before = index.path.read_bytes() + if code is None: + failure = PermissionError("Injected permission refusal.") + else: + failure = sqlite3.OperationalError("Injected SQLite storage refusal.") + failure.sqlite_errorcode = code + + def refuse(): + raise failure + + monkeypatch.setattr(index, "_open_owned", refuse) + with pytest.raises(type(failure)) as caught: + index.open(rebuild_invalid=True) + assert caught.value is failure + assert index.path.read_bytes() == before + assert not (index.path.parent / QUARANTINE_FILE_NAME).exists() + assert not index._recovery_attempted + assert index._lease_fd is None + + +def test_recovery_does_not_replace_valid_rows_during_derived_fts_repair(index): + from dataclasses import replace + + from app.services.directory_index import QUARANTINE_FILE_NAME + + index.write_paths(["accepted", "accepted/child"]) + assert index.finish_generation(1) + before = index.search("accepted") + index._connection().execute("DROP TABLE directory_fts") + index.close() + index.open(rebuild_invalid=True) + assert index.needs_fts_repair + assert replace(index.search("accepted"), query_plan=before.query_plan) == before + assert not (index.path.parent / QUARANTINE_FILE_NAME).exists() + assert not index._recovery_attempted + + +def test_fts_setup_corruption_cannot_trigger_replacement_of_validated_rows(index, monkeypatch): + from app.services.directory_index import QUARANTINE_FILE_NAME + + index.write_paths(["accepted"]) + assert index.finish_generation(1) + before = index.search("accepted") + index.close() + original = index._prepare_fts + + def fail(_connection): + error = sqlite3.DatabaseError("Injected derived-token reset corruption.") + error.sqlite_errorcode = sqlite3.SQLITE_CORRUPT + raise error + + monkeypatch.setattr(index, "_prepare_fts", fail) + with pytest.raises(RuntimeError, match="Validated directory data was retained"): + index.open(rebuild_invalid=True) + assert not (index.path.parent / QUARANTINE_FILE_NAME).exists() + assert not index._recovery_attempted + monkeypatch.setattr(index, "_prepare_fts", original) + index.open(rebuild_invalid=True) + assert index.search("accepted") == before + + +def test_failed_replacement_is_not_discarded_again_on_retry(index, monkeypatch): + from app.services.directory_index import QUARANTINE_FILE_NAME, DirectoryIndexRebuildRequired + + index.close() + before = index.path.read_bytes() + calls = 0 + + def fail(): + nonlocal calls + calls += 1 + if calls == 2: + error = sqlite3.OperationalError("Injected replacement storage failure.") + error.sqlite_errorcode = sqlite3.SQLITE_IOERR + raise error + raise DirectoryIndexRebuildRequired("Injected invalid directory metadata.") + + monkeypatch.setattr(index, "_open_owned", fail) + with pytest.raises(sqlite3.OperationalError): + index.open(rebuild_invalid=True) + assert index._recovery_attempted + rejected = index.path.parent / QUARANTINE_FILE_NAME + assert rejected.read_bytes() == before + with pytest.raises(RuntimeError, match="already failed after replacement"): + index.open(rebuild_invalid=True) + assert calls == 3 + assert rejected.read_bytes() == before + assert index._lease_fd is None + + +@pytest.mark.parametrize("sidecar_only", [False, True]) +def test_pressure_cleanup_accounts_for_quarantine_and_preserves_active_rows(index, sidecar_only): + from app.services.directory_index import QUARANTINE_FILE_NAME + from app.services.directory_index_resources import DirectoryIndexResources + + index.write_paths(["accepted"]) + assert index.finish_generation(1) + before = index.search("accepted") + resources = DirectoryIndexResources(index.path.parent) + resources.register_source(index.path) + before_bytes = resources.usage().managed_bytes + rejected = index.path.parent / QUARANTINE_FILE_NAME + if sidecar_only: + rejected = Path(str(rejected) + "-journal") + content = b"rejected disposable bytes" + rejected.write_bytes(content) + assert resources.usage().managed_bytes == before_bytes + len(content) + resources.cleanup_inactive(pressure=False) + assert rejected.read_bytes() == content + resources.cleanup_inactive(pressure=True) + assert not rejected.exists() + assert index.search("accepted") == before + assert resources.usage().managed_bytes == before_bytes + + +def test_pressure_cleanup_defers_owned_quarantine(index): + from app.services.directory_index import QUARANTINE_FILE_NAME + from app.services.directory_index_resources import DirectoryIndexResources + + resources = DirectoryIndexResources(index.path.parent) + rejected = index.path.parent / QUARANTINE_FILE_NAME + rejected.write_bytes(b"rejected") + descriptor = DirectoryIndex.acquire_storage_lease(rejected) + try: + resources.cleanup_inactive(pressure=True) + assert rejected.read_bytes() == b"rejected" + finally: + os.close(descriptor) + resources.cleanup_inactive(pressure=True) + assert not rejected.exists() + + +@pytest.mark.asyncio +async def test_backend_initialization_recovers_disposable_corruption_off_loop(sqlite_cache, monkeypatch): + import threading + from unittest.mock import AsyncMock + + cache = sqlite_cache + cache.index.close() + cache.index.path.write_bytes(b"corrupt disposable catalogue") + cache._opened.clear() + original = cache.index._open_owned + loop_thread = threading.get_ident() + threads = [] + + def opened(): + threads.append(threading.get_ident()) + original() + + monkeypatch.setattr(cache.index, "_open_owned", opened) + monkeypatch.setattr(cache, "_start_watcher", lambda: None) + monkeypatch.setattr(cache, "_start_periodic_rescan", lambda: None) + monkeypatch.setattr(cache, "_run_rescan", AsyncMock()) + await cache.start_scan() + await cache._scan_task + assert threads and all(thread != loop_thread for thread in threads) + assert cache._opened.is_set() + assert cache.index._recovery_attempted + assert cache.index.status().directory_count == 0 + assert cache._active_workers == 0 + assert cache.resources.usage().reserved_bytes == 0 + + +@pytest.mark.asyncio +async def test_sqlite_worker_never_uses_legacy_scans_or_snapshot_import(sqlite_cache, monkeypatch): + from app.services.directory_cache import ConnectionDirectoryCache + + cache = sqlite_cache + + for method in ("_run_scan", "_scan_single_directory", "load_from_disk", "_load_snapshot_file", "save_to_disk"): + assert not hasattr(ConnectionDirectoryCache, method) + assert not hasattr(cache, method) + assert not hasattr(cache, "_directories") + assert not hasattr(cache, "_rescan_changes") + monkeypatch.setattr(cache, "_start_watcher", lambda: None) + monkeypatch.setattr(cache, "_start_periodic_rescan", lambda: None) + monkeypatch.setattr("smbclient.scandir", lambda *_args, **_kwargs: (entry for entry in ())) + await cache._blocking(cache._scan_disk_queue) + assert cache.index.status().coverage == "complete" + assert cache.index.status().directory_count == 0 + assert cache.index.search("").total_matches == 0 + + +def test_namespace_lease_serializes_source_lease_creation(tmp_path): + import os + + namespace = tmp_path / "private" + source = DirectoryIndex(namespace / "source.sqlite3", source_kind="smb", source_identity={"id": "fixture"}) + descriptor = DirectoryIndex.acquire_namespace_lease(namespace) + try: + with pytest.raises(BlockingIOError): + source.open() + finally: + os.close(descriptor) + source.open() + try: + assert source.search("").directory_count == 0 + finally: + source.close() + + +@pytest.mark.skipif(os.name != "posix", reason="Descriptor-relative backend cleanup uses the Debian filesystem API") +@pytest.mark.parametrize("replacement", ["namespace_link", "namespace_directory", "database", "sidecar_link"]) +def test_inactive_deletion_refuses_replaced_storage_without_deleting_unrelated_files(index, monkeypatch, replacement): + from app.services.directory_index import connect_directory_database + from app.services.directory_index_resources import DirectoryIndexResources + + index.write_paths(["retained"]) + assert index.finish_generation(1) + namespace = index.path.parent + resources = DirectoryIndexResources(namespace) + index.close() + accessed = resources._inactive_access(index.path) + assert isinstance(accessed, int) + unrelated = namespace.with_name(f"{namespace.name}-unrelated") + unrelated.mkdir(mode=0o700) + victim = unrelated / index.path.name + contents = b"Unrelated data must not be deleted." + victim.write_bytes(contents) + original_namespace = namespace.with_name(f"{namespace.name}-original") + original_database = namespace / "original-source.sqlite3" + sidecar = Path(str(index.path) + "-journal") + original_connect = connect_directory_database + + def connect_and_replace(path, **kwargs): + connection = original_connect(path, **kwargs) + + def close(): + connection.close() + if replacement.startswith("namespace"): + namespace.rename(original_namespace) + if replacement == "namespace_link": + namespace.symlink_to(unrelated, target_is_directory=True) + else: + unrelated.rename(namespace) + elif replacement == "database": + index.path.rename(original_database) + index.path.write_bytes(contents) + else: + sidecar.symlink_to(victim) + + return SimpleNamespace(execute=connection.execute, close=close) + + monkeypatch.setattr("app.services.directory_index_resources.connect_directory_database", connect_and_replace) + try: + with pytest.raises(PermissionError): + resources._delete_inactive(index.path, accessed) + finally: + if namespace.is_symlink(): + namespace.unlink() + original_namespace.rename(namespace) + elif original_namespace.exists(): + namespace.rename(unrelated) + original_namespace.rename(namespace) + if original_database.exists(): + index.path.unlink() + original_database.rename(index.path) + if sidecar.is_symlink(): + sidecar.unlink() + assert victim.read_bytes() == contents + index.open() + assert index.search("retained").total_matches == 1 + + +def test_inactive_retention_count_and_leases_preserve_active_sources(tmp_path): + from app.models.system_settings import DirectoryIndexPolicy + from app.services.directory_index_resources import DirectoryIndexResources + + namespace = tmp_path / "private" + indexes = [] + for position, character in enumerate("abc", start=1): + source = DirectoryIndex(namespace / f"{character * 64}.sqlite3", source_kind="smb", source_identity={"id": character}) + source.open() + source.write_paths(["accepted"]) + source._connection().execute("UPDATE source_metadata SET last_access_at_ms = ? WHERE id = 1", (position * 1000,)) + indexes.append(source) + indexes[0].close() + indexes[1].close() + resources = DirectoryIndexResources(namespace, DirectoryIndexPolicy(max_retained_inactive_sources=1)) + try: + assert resources.cleanup_inactive(now_ms=4000) == 1 + assert not indexes[0].path.exists() + assert indexes[1].path.exists() + assert indexes[2].path.exists() + assert indexes[2].search("accepted").total_matches == 1 + resources.register_source(indexes[1].path) + assert resources.cleanup_inactive(now_ms=1_000_000_000) == 0 + resources.release_source(indexes[1].path) + assert resources.cleanup_inactive(now_ms=1_000_000_000) == 1 + assert indexes[2].search("accepted").total_matches == 1 + finally: + for source in indexes: + source.close() + + +def test_orphan_lease_cleanup_is_bounded_and_preserves_owners_and_sidecars(tmp_path): + import os + + from app.services.directory_index_resources import LEASE_CLEANUP_BATCH, DirectoryIndexResources + + namespace = tmp_path / "private" + namespace.mkdir(mode=0o700) + resources = DirectoryIndexResources(namespace) + for number in range(LEASE_CLEANUP_BATCH + 2): + (namespace / f"{number:064x}.sqlite3.lease").write_bytes(b"") + held = namespace / f"{'b' * 64}.sqlite3" + descriptor = DirectoryIndex.acquire_storage_lease(held) + sidecar = namespace / f"{'c' * 64}.sqlite3" + Path(str(sidecar) + ".lease").write_bytes(b"") + Path(str(sidecar) + "-wal").write_bytes(b"held") + admitted = namespace / f"{'d' * 64}.sqlite3" + Path(str(admitted) + ".lease").write_bytes(b"") + resources.register_source(admitted) + try: + namespace_descriptor = DirectoryIndex.acquire_namespace_lease(namespace) + try: + assert resources.cleanup_orphan_leases() == 0 + finally: + os.close(namespace_descriptor) + assert resources.cleanup_orphan_leases() == LEASE_CLEANUP_BATCH + assert resources.cleanup_orphan_leases() == 2 + assert Path(str(held) + ".lease").exists() + assert Path(str(sidecar) + ".lease").exists() + assert Path(str(admitted) + ".lease").exists() + finally: + os.close(descriptor) + resources.release_source(admitted) + assert resources.cleanup_orphan_leases() == 2 + assert Path(str(sidecar) + ".lease").exists() + + +def test_retired_cleanup_is_bounded_and_preserves_selected_metadata(index): + from app.services.directory_index import ROW_BATCH_SIZE + + index.write_paths(f"old/{number}" for number in range(ROW_BATCH_SIZE * 2)) + index.finish_generation(1) + candidate = index.begin_verification() + index.write_paths(["selected"], generation_id=candidate) + assert index.finish_generation(candidate) + before = index.search("selected") + assert index.cleanup_retired_batch() + remaining = index._connection().execute("SELECT indexed_count FROM generations WHERE id = 1").fetchone() + assert remaining == (ROW_BATCH_SIZE,) + assert not index.cleanup_retired_batch() + assert index.search("selected") == before + assert index._connection().execute("SELECT 1 FROM generations WHERE id = 1").fetchone() is None + index._connection().execute("INSERT INTO directory_fts(directory_fts, rank) VALUES ('integrity-check', 1)") + + +def test_source_registration_survives_previous_owner_release(tmp_path): + from app.services.directory_index_resources import DirectoryIndexResources + + resources = DirectoryIndexResources(tmp_path / "sqlite") + path = resources.namespace / "source.sqlite3" + resources.register_source(path) + resources.register_source(path) + resources.release_source(path) + assert resources.active_paths[path] == 1 + resources.release_source(path) + assert path not in resources.active_paths + + +def test_incremental_reclamation_preserves_results_and_verification(index): + from app.services.directory_index import ROW_BATCH_SIZE + + index.write_paths(f"old/{number}/{'long-name-' * 100}" for number in range(ROW_BATCH_SIZE * 2)) + index.finish_generation(1) + candidate = index.begin_verification() + index.write_paths(["selected"], generation_id=candidate) + index.finish_generation(candidate) + while index.cleanup_retired_batch(): + pass + before = index.search("selected") + pages_before = index._connection().execute("PRAGMA page_count").fetchone()[0] + assert index._connection().execute("PRAGMA freelist_count").fetchone()[0] > 0 + with closing(index._connect(readonly=True)) as reader: + reader.execute("BEGIN") + reader.execute("SELECT count(*) FROM directories").fetchone() + index.touch_access(1) + assert not index.reclaim_pages() + assert index._connection().execute("PRAGMA page_count").fetchone()[0] == pages_before + reader.execute("ROLLBACK") + assert index.reclaim_pages() + assert index._connection().execute("PRAGMA page_count").fetchone()[0] < pages_before + assert index.search("selected") == before + + +@pytest.mark.asyncio +async def test_update_loop_defers_checkpoint_until_foreground_mutations_drain(sqlite_cache, monkeypatch): + from smbprotocol.change_notify import FileAction + + from app.services import directory_cache + from app.services.directory_cache import _DirectoryMutation + from app.services.directory_index import ROW_BATCH_SIZE + + for number in range(ROW_BATCH_SIZE + 1): + assert sqlite_cache.coordinator.enqueue(_DirectoryMutation(FileAction.FILE_ACTION_ADDED, f"foreground/{number}")) + ticks = 0 + checkpoints = [] + + async def tick(_delay): + nonlocal ticks + ticks += 1 + if ticks > 1: + raise asyncio.CancelledError + + async def run(operation): + return operation() + + monkeypatch.setattr(directory_cache, "asyncio", SimpleNamespace(sleep=tick, CancelledError=asyncio.CancelledError)) + monkeypatch.setattr(sqlite_cache, "_blocking", run) + monkeypatch.setattr(sqlite_cache, "_verification_due", lambda _verified_at: False) + monkeypatch.setattr(sqlite_cache.index, "checkpoint_if_due", lambda: checkpoints.append(len(sqlite_cache.coordinator.pending))) + await sqlite_cache._periodic_rescan_loop() + assert len(sqlite_cache.coordinator.pending) == 1 + assert not checkpoints + ticks = 0 + await sqlite_cache._periodic_rescan_loop() + assert not sqlite_cache.coordinator.pending + assert checkpoints == [0] + assert sqlite_cache.index.search("foreground").total_matches == ROW_BATCH_SIZE + 1 + + +@pytest.mark.parametrize("blocker", ["mutation", "query", "scan", "stop"]) +def test_checkpoint_worker_defers_for_foreground_ownership(sqlite_cache, monkeypatch, blocker): + from smbprotocol.change_notify import FileAction + + from app.services.directory_cache import _DirectoryMutation + + calls = [] + monkeypatch.setattr(sqlite_cache.index, "checkpoint_if_due", lambda: calls.append(True)) + if blocker == "mutation": + assert sqlite_cache.coordinator.enqueue(_DirectoryMutation(FileAction.FILE_ACTION_ADDED, "foreground")) + elif blocker == "query": + monkeypatch.setattr(sqlite_cache.index, "_active_queries", 1) + elif blocker == "scan": + sqlite_cache._scan_running.set() + else: + sqlite_cache._stop_event.set() + sqlite_cache._checkpoint_work() + assert not calls + if blocker == "mutation": + sqlite_cache.coordinator.apply_pending() + elif blocker == "query": + monkeypatch.setattr(sqlite_cache.index, "_active_queries", 0) + elif blocker == "scan": + sqlite_cache._scan_running.clear() + else: + sqlite_cache._stop_event.clear() + sqlite_cache._checkpoint_work() + assert calls == [True] + + +def test_checkpoint_worker_retries_pinned_reader_and_refused_headroom(sqlite_cache, monkeypatch): + from app.services.directory_index import CHECKPOINT_WAL_BYTES, ROW_BATCH_SIZE + from app.services.directory_index_resources import DirectoryDiskBudgetError + + index = sqlite_cache.index + index._connection().execute("PRAGMA wal_autocheckpoint = 0") + index.write_paths(["accepted"]) + assert index.finish_generation(1) + with closing(index._connect(readonly=True)) as reader: + reader.execute("BEGIN") + reader.execute("SELECT count(*) FROM directories").fetchone() + index.write_paths(f"match/{number}/{'long-name-' * 100}" for number in range(ROW_BATCH_SIZE * 4)) + before = index.search("match accepted") + wal = Path(str(index.path) + "-wal") + assert wal.stat().st_size >= CHECKPOINT_WAL_BYTES + sqlite_cache._checkpoint_work() + assert wal.stat().st_size >= CHECKPOINT_WAL_BYTES + assert index.search("match accepted") == before + reader.execute("ROLLBACK") + + def denied(): + raise DirectoryDiskBudgetError("Injected checkpoint headroom refusal.") + + with monkeypatch.context() as refusal: + refusal.setattr(index, "growth_guard", denied) + with pytest.raises(DirectoryDiskBudgetError, match="checkpoint headroom"): + sqlite_cache._checkpoint_work() + assert wal.stat().st_size >= CHECKPOINT_WAL_BYTES + assert index.search("match accepted") == before + sqlite_cache._checkpoint_work() + assert wal.stat().st_size == 0 + assert index.search("match accepted") == before + + +def test_small_wal_does_not_request_checkpoint_admission(index, monkeypatch): + def unexpected_admission(): + raise AssertionError("A small WAL must not request maintenance admission.") + + monkeypatch.setattr(index, "growth_guard", unexpected_admission) + assert index.checkpoint_if_due() + + +def test_normal_checkpoint_defers_for_pinned_reader_without_changing_results(index): + from app.services.directory_index import CHECKPOINT_WAL_BYTES, ROW_BATCH_SIZE + + index._connection().execute("PRAGMA wal_autocheckpoint = 0") + with closing(index._connect(readonly=True)) as reader: + reader.execute("BEGIN") + reader.execute("SELECT count(*) FROM directories").fetchone() + index.write_paths(f"match/{number}/{'long-name-' * 100}" for number in range(ROW_BATCH_SIZE * 4)) + before = index.search("match") + wal = Path(str(index.path) + "-wal") + assert wal.stat().st_size >= CHECKPOINT_WAL_BYTES + assert not index.checkpoint_if_due() + assert wal.stat().st_size >= CHECKPOINT_WAL_BYTES + reader.execute("ROLLBACK") + assert index.checkpoint_if_due() + assert wal.stat().st_size == 0 + assert index.search("match") == before + + +@pytest.mark.parametrize("missing_table", [False, True]) +def test_fts_repair_is_bounded_restartable_and_preserves_rows(index, missing_table): + from dataclasses import replace + + from app.services.directory_index import ROW_BATCH_SIZE + + index.write_paths(f"match/{number}" for number in range(ROW_BATCH_SIZE + 1)) + index.finish_generation(1) + before = index.search("match") + index._connection().execute( + "DROP TABLE directory_fts" if missing_table else "INSERT INTO directory_fts(directory_fts) VALUES ('delete-all')" + ) + index.close() + index.open() + assert index.needs_fts_repair + assert index.search("match") == replace(before, query_plan="scan") + with pytest.raises(RuntimeError, match="being rebuilt"): + index.mutate_path("remove", "match/0") + assert index.repair_fts_batch() + assert index._connection().execute("SELECT count(*) FROM directory_fts_docsize").fetchone()[0] == ROW_BATCH_SIZE + index.close() + index.open() + assert index.needs_fts_repair + assert index.repair_fts_batch() + assert not index.repair_fts_batch() + assert not index.needs_fts_repair + assert index.search("match") == before + index.mutate_path("add", "match/extra") + assert index.search("match").total_matches == before.total_matches + 1 + + +@pytest.mark.parametrize("error_code", [sqlite3.SQLITE_FULL, sqlite3.SQLITE_IOERR]) +def test_failed_partial_fts_batch_preserves_rows_cursor_and_retry(index, monkeypatch, error_code): + from dataclasses import replace + + index.write_paths(["accepted/first", "accepted/second"]) + index.finish_generation(1) + index._connection().execute("INSERT INTO directory_fts(directory_fts) VALUES ('delete-all')") + index.close() + index.open() + assert index.needs_fts_repair + before = index.search("accepted") + cursor = index._fts_repair_cursor + connection = index._connection() + failure = sqlite3.OperationalError("Injected directory-token storage failure.") + failure.sqlite_errorcode = error_code + + class PartialWriteFailure: + def __enter__(self): + connection.__enter__() + return self + + def __exit__(self, exception_type, exception, traceback): + return connection.__exit__(exception_type, exception, traceback) + + def __getattr__(self, name): + return getattr(connection, name) + + def executemany(self, statement, rows): + connection.execute(statement, next(iter(rows))) + raise failure + + with monkeypatch.context() as patcher: + patcher.setattr(index, "_connection", PartialWriteFailure) + with pytest.raises(sqlite3.OperationalError) as caught: + index.repair_fts_batch() + assert caught.value is failure + assert index._fts_repair_cursor == cursor + assert index.needs_fts_repair + assert connection.execute("SELECT count(*) FROM directory_fts_docsize").fetchone()[0] == 0 + assert index.search("accepted") == before + assert not index.repair_fts_batch() + assert not index.needs_fts_repair + assert index.search("accepted") == replace(before, query_plan="trigram") + + +def test_source_close_releases_registration_once(sqlite_cache): + sqlite_cache.resources.register_source(sqlite_cache._persist_path) + sqlite_cache.resources.register_source(sqlite_cache._persist_path) + sqlite_cache._request_stop() + sqlite_cache._finish_stop() + sqlite_cache._finish_stop() + assert sqlite_cache.resources.active_paths[sqlite_cache._persist_path] == 1 + with pytest.raises(InterruptedError, match="replaced or stopped"): + sqlite_cache.search_index("accepted") + + +def test_background_fts_repair_releases_actual_scan_ownership_after_failure(sqlite_cache, monkeypatch): + index = sqlite_cache.index + index.write_paths(["accepted"]) + index._connection().execute("INSERT INTO directory_fts(directory_fts) VALUES ('delete-all')") + index.close() + index.open() + original = index.repair_fts_batch + + def fail(): + raise OSError("Injected storage failure.") + + monkeypatch.setattr(index, "repair_fts_batch", fail) + with pytest.raises(OSError, match="storage failure"): + sqlite_cache._repair_fts_work() + assert not sqlite_cache._scan_running.is_set() + assert sqlite_cache._manager_scan_slots.acquire(blocking=False) + sqlite_cache._manager_scan_slots.release() + assert index.search("accepted").total_matches == 1 + monkeypatch.setattr(index, "repair_fts_batch", original) + sqlite_cache._repair_fts_work() + assert not index.needs_fts_repair + assert index.search("accepted").query_plan == "trigram" + + +def test_pressure_reclamation_coalesces_and_defers_for_actual_readers(sqlite_cache, monkeypatch): + from smbprotocol.change_notify import FileAction + + from app.services.directory_cache import _DirectoryMutation + from app.services.directory_index_resources import DirectoryDiskBudgetError + + outcomes = iter([False, True]) + calls = [] + + def reclaim(): + calls.append(True) + return next(outcomes) + + monkeypatch.setattr(sqlite_cache.index, "reclaim_pages", reclaim) + sqlite_cache._reclaim_disk_work() + assert not calls + sqlite_cache._reclamation_pending = True + assert sqlite_cache.coordinator.enqueue(_DirectoryMutation(FileAction.FILE_ACTION_ADDED, "foreground")) + sqlite_cache._reclaim_disk_work() + assert not calls + assert sqlite_cache._reclamation_pending + sqlite_cache.coordinator.apply_pending() + before = sqlite_cache.index.search("foreground") + sqlite_cache._scan_running.set() + sqlite_cache._reclaim_disk_work() + assert not calls + assert sqlite_cache._reclamation_pending + sqlite_cache._scan_running.clear() + with monkeypatch.context() as reader: + reader.setattr(sqlite_cache.index, "_active_queries", 1) + sqlite_cache._reclaim_disk_work() + assert not calls + assert sqlite_cache._reclamation_pending + with monkeypatch.context() as refusal: + + def denied(): + raise DirectoryDiskBudgetError("Injected maintenance headroom refusal.") + + refusal.setattr(sqlite_cache.index, "reclaim_pages", denied) + with pytest.raises(DirectoryDiskBudgetError): + sqlite_cache._reclaim_disk_work() + assert sqlite_cache._reclamation_pending + assert sqlite_cache.index.search("foreground") == before + sqlite_cache._reclaim_disk_work() + assert sqlite_cache._reclamation_pending + sqlite_cache._reclaim_disk_work() + assert not sqlite_cache._reclamation_pending + sqlite_cache._reclaim_disk_work() + assert len(calls) == 2 + assert sqlite_cache.index.search("foreground") == before + + +def test_last_access_update_does_not_change_search_or_verification(index): + index.write_paths(["accepted"]) + index.finish_generation(1) + before = index.search("accepted") + index.touch_access(1000) + assert index._connection().execute("SELECT last_access_at_ms FROM source_metadata WHERE id = 1").fetchone() == (1000,) + assert index.search("accepted") == before + + +def test_directory_disk_admission_preserves_rows_and_releases_reservations(index): + from app.models.system_settings import DirectoryIndexPolicy + from app.services.directory_index_resources import DirectoryDiskBudgetError, DirectoryIndexResources + + index.write_paths(["accepted"]) + before = index.search("accepted") + resources = DirectoryIndexResources(index.path.parent, DirectoryIndexPolicy(disk_budget_bytes=64 * 1024 * 1024)) + index.growth_guard = resources.reserve_growth + with resources.reserve_growth(): + assert resources.usage().reserved_bytes > 0 + with pytest.raises(DirectoryDiskBudgetError, match="headroom is unavailable"): + index.write_paths(["refused"]) + assert resources.usage().reserved_bytes == 0 + assert index.search("accepted") == before + assert index.search("refused").total_matches == 0 + resources.apply_policy(DirectoryIndexPolicy()) + index.write_paths(["restored"]) + assert index.search("restored").total_matches == 1 + + +def test_directory_disk_accounting_rejects_links_without_reading_target(tmp_path): + from app.services.directory_index_resources import DirectoryIndexResources + + namespace = tmp_path / "sqlite" + namespace.mkdir() + outside = tmp_path / "outside" + outside.write_bytes(b"private") + (namespace / "linked.sqlite3").symlink_to(outside) + with pytest.raises(ValueError, match="unsupported entries"): + DirectoryIndexResources(namespace).usage() + assert outside.read_bytes() == b"private" + + +@pytest.mark.asyncio +async def test_backend_idle_release_preserves_sources_with_actual_work(sqlite_cache, monkeypatch): + import time + + from app.core.directory_index_contract import DIRECTORY_INDEX_CONTRACT + from app.services.directory_cache import DirectoryCacheManager + + manager = DirectoryCacheManager() + manager._caches[sqlite_cache.connection_id] = sqlite_cache + stopped = [] + monkeypatch.setattr(sqlite_cache, "_finish_stop", lambda: stopped.append(sqlite_cache.connection_id)) + sqlite_cache._last_access = time.monotonic() - DIRECTORY_INDEX_CONTRACT.limits["idle_release_seconds"] - 1 + sqlite_cache._scan_running.set() + await manager.release_idle_caches() + assert not stopped + sqlite_cache._scan_running.clear() + sqlite_cache._active_workers = 1 + await manager.release_idle_caches() + assert not stopped + sqlite_cache._active_workers = 0 + await manager.release_idle_caches() + assert stopped == [sqlite_cache.connection_id] + assert manager.get_cache(sqlite_cache.connection_id) is None + assert not manager._retiring_caches + + +@pytest.mark.asyncio +async def test_failed_initialization_keeps_bounded_retry_eligibility(sqlite_cache, monkeypatch): + import time + + sqlite_cache.index.close() + sqlite_cache._opened.clear() + started = [] + + def unavailable(): + raise BlockingIOError("leased") + + monkeypatch.setattr(sqlite_cache, "_initialize_storage", unavailable) + monkeypatch.setattr(sqlite_cache, "_start_periodic_rescan", lambda: started.append(True)) + await sqlite_cache._initialize() + assert sqlite_cache._resource_retry_at > time.monotonic() + assert "Wait for active workers" in sqlite_cache.scan_error + assert started == [True] + assert not sqlite_cache._opened.is_set() + + +@pytest.mark.asyncio +async def test_repeated_cold_requests_respect_retry_deadline(sqlite_cache, monkeypatch): + from unittest.mock import AsyncMock + + sqlite_cache._opened.clear() + sqlite_cache._schedule_retry(now=1000.0) + deadline = sqlite_cache._resource_retry_at + now = [deadline - 0.001] + initialize = AsyncMock() + monkeypatch.setattr(sqlite_cache, "_initialize", initialize) + monkeypatch.setattr("app.services.directory_cache.time", SimpleNamespace(monotonic=lambda: now[0])) + for _ in range(3): + await sqlite_cache.start_scan() + if sqlite_cache._scan_task is not None: + await sqlite_cache._scan_task + initialize.assert_not_awaited() + assert sqlite_cache._resource_retry_at == deadline + now[0] = deadline + await sqlite_cache.start_scan() + assert sqlite_cache._scan_task is not None + await sqlite_cache._scan_task + initialize.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_source_replacement_does_not_wait_for_old_close(sqlite_cache, monkeypatch): + from app.services.directory_cache import DirectoryCacheManager, SQLiteConnectionDirectoryCache + + manager = DirectoryCacheManager() + manager._caches[sqlite_cache.connection_id] = sqlite_cache + release = threading.Event() + entered = asyncio.Event() + loop = asyncio.get_running_loop() + + def finish(): + loop.call_soon_threadsafe(entered.set) + assert release.wait(timeout=5) + + async def initialize_later(_cache): + return None + + monkeypatch.setattr(sqlite_cache, "_finish_stop", finish) + monkeypatch.setattr(SQLiteConnectionDirectoryCache, "start_scan", initialize_later) + try: + replacement = await asyncio.wait_for( + manager.get_or_create_cache( + sqlite_cache.connection_id, + sqlite_cache.host, + sqlite_cache.share_name, + sqlite_cache.username, + "replacement-password", + sqlite_cache.port, + ), + timeout=1, + ) + await asyncio.wait_for(entered.wait(), timeout=5) + assert replacement is not sqlite_cache + assert sqlite_cache in manager._retiring_caches + assert sqlite_cache._stop_event.is_set() + finally: + release.set() + await manager.stop_all_async() + + +@pytest.mark.asyncio +async def test_cancelled_idle_cleanup_keeps_actual_close_ownership(sqlite_cache, monkeypatch): + import time + + from app.core.directory_index_contract import DIRECTORY_INDEX_CONTRACT + from app.services.directory_cache import DirectoryCacheManager + + manager = DirectoryCacheManager() + manager._caches[sqlite_cache.connection_id] = sqlite_cache + sqlite_cache._last_access = time.monotonic() - DIRECTORY_INDEX_CONTRACT.limits["idle_release_seconds"] - 1 + entered = asyncio.Event() + release = threading.Event() + loop = asyncio.get_running_loop() + + def finish(): + loop.call_soon_threadsafe(entered.set) + assert release.wait(timeout=5) + + monkeypatch.setattr(sqlite_cache, "_finish_stop", finish) + cleanup = asyncio.create_task(manager.release_idle_caches()) + try: + await asyncio.wait_for(entered.wait(), timeout=5) + cleanup.cancel() + with pytest.raises(asyncio.CancelledError): + await cleanup + assert sqlite_cache in manager._retiring_caches + finally: + release.set() + await manager.stop_all_async() + assert not manager._retiring_caches + + +def test_directory_disk_admission_checks_filesystem_headroom_and_retains_rows(index, monkeypatch): + from types import SimpleNamespace + + from app.services.directory_index_resources import WRITE_HEADROOM_BYTES, DirectoryDiskBudgetError, DirectoryIndexResources + + index.write_paths(["accepted"]) + before = index.search("accepted") + resources = DirectoryIndexResources(index.path.parent) + index.growth_guard = resources.reserve_growth + monkeypatch.setattr( + "app.services.directory_index_resources.shutil.disk_usage", lambda _path: SimpleNamespace(free=WRITE_HEADROOM_BYTES - 1) + ) + with pytest.raises(DirectoryDiskBudgetError, match="insufficient free space"): + index.write_paths(["refused"]) + assert resources.usage().growth_paused + assert resources.usage().reserved_bytes == 0 + assert index.search("accepted") == before + assert index.search("refused").total_matches == 0 + monkeypatch.setattr( + "app.services.directory_index_resources.shutil.disk_usage", lambda _path: SimpleNamespace(free=WRITE_HEADROOM_BYTES) + ) + index.write_paths(["restored"]) + assert index.search("restored").total_matches == 1 + + +def test_page_cache_policy_applies_to_connections_without_changing_coverage(index): + index.write_paths(["accepted"]) + assert index.finish_generation(1) + before = index.search("accepted") + index.configure_page_cache(64) + with closing(index._connect(readonly=True)) as connection: + assert connection.execute("PRAGMA cache_size").fetchone() == (-64,) + assert connection.execute("PRAGMA mmap_size").fetchone() == (0,) + assert index.search("accepted") == before + with pytest.raises(ValueError, match="integer between"): + index.configure_page_cache(0) + + +def test_trigram_query_streams_candidates_without_temporary_id_list(index, monkeypatch): + traced = [] + original_connect = index._connect + + def connect(**kwargs): + connection = original_connect(**kwargs) + connection.set_trace_callback(traced.append) + return connection + + index.write_paths(["alpha/beta", "alpha", "beta", "neither"]) + monkeypatch.setattr(index, "_connect", connect) + assert index.search("alpha beta").total_matches == 3 + statement = next(statement for statement in traced if "CROSS JOIN directories" in statement) + details = [row[3] for row in index._connection().execute("EXPLAIN QUERY PLAN " + statement)] + assert all("LIST SUBQUERY" not in detail and "TEMP B-TREE" not in detail for detail in details) + + +def test_resource_policy_model_uses_shared_defaults_and_scope_keys(): + from app.core.directory_index_contract import DIRECTORY_INDEX_CONTRACT + from app.core.system_setting_definitions import DIRECTORY_INDEX_SETTING_KEYS, SYSTEM_SETTING_DEFINITIONS, DirectoryIndexPolicyScope + from app.models.system_settings import DirectoryIndexPolicy + + policy = DirectoryIndexPolicy() + for scope in DirectoryIndexPolicyScope: + for field, definition in DIRECTORY_INDEX_CONTRACT.policy.items(): + assert getattr(policy, field) == definition.default + assert SYSTEM_SETTING_DEFINITIONS[DIRECTORY_INDEX_SETTING_KEYS[scope][field]].default_value == definition.default + + +@pytest.mark.parametrize("invalid", [0, True, "2048", 2048.5]) +def test_resource_policy_model_rejects_unsafe_values(invalid): + from app.models.system_settings import DirectoryIndexPolicy + + with pytest.raises(ValueError): + DirectoryIndexPolicy(page_cache_kib=invalid) + + +def test_resource_policy_model_validates_correlated_intervals(): + from app.models.system_settings import DirectoryIndexPolicy + + with pytest.raises(ValueError, match="must not exceed"): + DirectoryIndexPolicy(healthy_verification_seconds=60, degraded_verification_seconds=300) + + +@pytest.mark.parametrize("invalid", [0, True, "2", -1]) +def test_shared_resource_contract_rejects_invalid_limits(tmp_path, invalid): + from app.core.directory_index_contract import load_directory_index_contract + + contract = json.loads((CONTRACT_ROOT / "contract.json").read_text()) + contract["limits"]["query_workers"] = invalid + path = tmp_path / "invalid.json" + path.write_text(json.dumps(contract), encoding="utf-8") + with pytest.raises(ValueError, match="limits are invalid"): + load_directory_index_contract(path) + + +@pytest.fixture +def sqlite_cache(index): + from app.services.directory_cache import DirectoryScanCoordinator, SQLiteConnectionDirectoryCache + + cache = SQLiteConnectionDirectoryCache("fixture", "fixture", "share", "user", "password") + cache.index = index + from app.services.directory_index_resources import DirectoryIndexResources + + cache._persist_path = index.path + cache.resources = DirectoryIndexResources(index.path.parent) + cache.index.growth_guard = cache.resources.reserve_growth + cache.coordinator = DirectoryScanCoordinator(index) + cache._opened.set() + cache._source_available.set() + cache._source_probe_completed.set() + + class SourceRead: + def __init__(self, entries): + self.entries = entries + + def __next__(self): + return next(self.entries) + + def close(self): + self.entries.close() + + def verify(self): + pass + + def has_directory_child(self, name): + return True + + def open_read(parent, token): + import smbclient + + from app.services.system_settings import get_smbclient_policy_kwargs + + path = cache._smb_base_path + ("\\" + parent.replace("/", "\\") if parent else "") + return SourceRead( + smbclient.scandir( + path, + username=cache.username, + password=cache.password, + port=cache.port, + connection_cache=cache._connection_cache, + **get_smbclient_policy_kwargs(), + ) + ) + + with pytest.MonkeyPatch.context() as reader_patch: + reader_patch.setattr(cache, "_open_source_read", open_read) + try: + yield cache + finally: + cache._request_stop() + cache._finish_stop() + + +def test_smb_handle_uses_no_redirect_open_and_bounded_native_reads(monkeypatch): + from types import SimpleNamespace + + from smbprotocol.exceptions import PathNotCovered + from smbprotocol.open import CreateOptions, ShareAccess + + from app.services.directory_traversal import DIRECTORY_READ_BUFFER_BYTES, SmbDirectoryHandle + + calls = [] + replies = [[object()], _smb_response_error(NtStatus.STATUS_NO_MORE_FILES), _smb_response_error(NtStatus.STATUS_PATH_NOT_COVERED)] + + class Handle: + connected = True + file_attributes = 0 + + def __init__(self, tree, path): + calls.append((tree, path)) + + def create(self, *args): + calls.append(args) + + def query_directory(self, *args, **kwargs): + calls.append((args, kwargs)) + reply = replies.pop(0) + if isinstance(reply, BaseException): + raise reply + return reply + + def close(self): + self.connected = False + + tree = SimpleNamespace(share_name=r"\\host\share", is_dfs_share=False) + monkeypatch.setattr("app.services.directory_traversal.get_smb_tree", lambda *args, **kwargs: (tree, "parent")) + monkeypatch.setattr("app.services.directory_traversal.Open", Handle) + handle = SmbDirectoryHandle(r"\\host\share\parent", lambda: None) + assert calls[1][3] == ShareAccess.FILE_SHARE_READ | ShareAccess.FILE_SHARE_WRITE + assert calls[1][5] & CreateOptions.FILE_OPEN_REPARSE_POINT + assert len(list(handle.records(lambda: None))) == 1 + assert calls[2][1]["max_output"] == DIRECTORY_READ_BUFFER_BYTES + assert calls[2][1]["flags"] != 0 + assert calls[3][1]["flags"] == 0 + with pytest.raises(PathNotCovered): + list(handle.records(lambda: None)) + handle.close() + assert handle.closed + + +@pytest.fixture +def smb_directory_server(monkeypatch): + from datetime import datetime, timezone + from types import SimpleNamespace + from uuid import UUID + + from smbprotocol.file_info import FileFsVolumeInformation, FileIdFullDirectoryInformation + + server = SimpleNamespace( + directories={"": [("base", 16)], "base": [("parent", 16)], "base\\parent": [("Exact", 16), ("file", 0), ("redirect", 16 | 1024)]}, + identifiers={}, + volumes={}, + names={}, + handles=[], + patterns=[], + denied=set(), + metadata_denied=set(), + metadata_error=None, + volume_error=None, + volume_queries=0, + on_query=lambda: None, + connection=SimpleNamespace(server_guid=UUID(int=1)), + ) + server.tree = SimpleNamespace(share_name=r"\\host\share", is_dfs_share=False, tree_connect_id=1, session=SimpleNamespace(session_id=1)) + + class Handle: + connected = False + file_attributes = 16 + + def __init__(self, tree, path): + self.path = path + self.tree_connect = tree + self.connection = server.connection + self.cursor = 0 + server.handles.append(self) + + def create(self, *args): + if self.path not in server.directories: + raise FileNotFoundError("Injected missing directory.") + self.connected = True + + def close(self): + self.connected = False + + def query_directory(self, pattern, info_class, *, flags, max_output): + if self.path in server.denied: + raise _smb_response_error(NtStatus.STATUS_ACCESS_DENIED) + server.on_query() + server.patterns.append(pattern) + if flags: + self.cursor = 0 + entries = server.directories[self.path] + if pattern != "*": + entries = [(name, attributes) for name, attributes in entries if name.casefold() == pattern.casefold()] + if self.cursor >= len(entries): + raise _smb_response_error(NtStatus.STATUS_NO_MORE_FILES) + records = [] + for name, attributes in entries[self.cursor : self.cursor + 2]: + record = FileIdFullDirectoryInformation() + record["file_name"] = name.encode("utf-16-le", errors="surrogatepass") + record["file_attributes"] = attributes + records.append(record) + self.cursor += len(records) + return records + + class Transaction: + def __init__(self, raw): + self.raw = raw + self.results = () + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def metadata(transaction, kind, **kwargs): + information = kind() + path = transaction.raw.fd.path + if path in server.metadata_denied: + raise server.metadata_error if server.metadata_error is not None else _smb_response_error(NtStatus.STATUS_ACCESS_DENIED) + if isinstance(information, FileFsVolumeInformation): + server.volume_queries += 1 + if server.volume_error is not None: + raise server.volume_error + information["volume_serial_number"] = server.volumes.get(path, 1) + information["volume_creation_time"] = datetime(2026, 1, 1, tzinfo=timezone.utc) + else: + information["internal_information"].get_value()["index_number"] = server.identifiers.setdefault( + path, len(server.identifiers) + 1 + ) + information["name_information"].get_value()["file_name"] = server.names.get(path, "\\" + path) + information["standard_information"].get_value()["directory"] = True + transaction.results += (information,) + + def tree(path, **kwargs): + return server.tree, "\\".join(PureWindowsPath(path).parts[1:]) + + from pathlib import PureWindowsPath + + monkeypatch.setattr("app.services.directory_traversal.get_smb_tree", tree) + monkeypatch.setattr("app.services.directory_traversal.Open", Handle) + monkeypatch.setattr("app.services.directory_traversal.SMBFileTransaction", Transaction) + monkeypatch.setattr("app.services.directory_traversal.query_info", metadata) + yield server + assert all(not handle.connected for handle in server.handles) + + +@pytest.mark.parametrize("bound", ["depth", "bytes"]) +def test_smb_traversal_bounds_reject_before_open_and_preserve_exact_limits(smb_directory_server, monkeypatch, bound): + from pathlib import PureWindowsPath + + from app.services.directory_traversal import ( + MAX_SOURCE_DEPTH, + MAX_SOURCE_PATH_BYTES, + DirectoryTraversalError, + SmbDirectoryRoot, + _validate_traversal_path, + ) + + if bound == "depth": + allowed = "/".join(["entry"] * MAX_SOURCE_DEPTH) + refused = f"{allowed}/child" + else: + allowed = "\u00e9" * (MAX_SOURCE_PATH_BYTES // 2) + refused = f"{allowed}\u00e9" + _validate_traversal_path(allowed) + with pytest.raises(DirectoryTraversalError, match="traversal bounds"): + _validate_traversal_path(refused) + root = SmbDirectoryRoot(r"\\host\share") + try: + with pytest.raises(DirectoryTraversalError, match="traversal bounds"): + root.read(refused, lambda: None) + with pytest.raises(DirectoryTraversalError, match="traversal bounds"): + SmbDirectoryRoot(str(PureWindowsPath(r"\\host\share", *refused.split("/")))) + assert not smb_directory_server.handles + finally: + root.close() + + with monkeypatch.context() as bounds: + if bound == "depth": + bounds.setattr("app.services.directory_traversal.MAX_SOURCE_DEPTH", 2) + else: + bounds.setattr("app.services.directory_traversal.MAX_SOURCE_PATH_BYTES", len("base/parent".encode("utf-8"))) + root = SmbDirectoryRoot(r"\\host\share\base") + try: + read = root.read("parent", lambda: None) + try: + assert [entry.name for entry in read] == [None, "file", None] + finally: + read.close() + finally: + root.close() + + +@pytest.mark.parametrize("changed", [False, True]) +def test_smb_root_replacement_binds_fresh_identity_across_sessions(smb_directory_server, changed): + from app.services.directory_traversal import SmbDirectoryRoot + + root = SmbDirectoryRoot(r"\\host\share\base") + root.read("", lambda: None).close() + replacement = root.replacement() + smb_directory_server.tree.session.session_id += 1 + smb_directory_server.tree.tree_connect_id += 1 + if changed: + smb_directory_server.identifiers["base"] += 1 + try: + replacement.read("", lambda: None).close() + finally: + replacement.close() + + +@pytest.mark.asyncio +async def test_directory_rebinding_waits_for_retained_workers(sqlite_cache, monkeypatch): + from unittest.mock import MagicMock + + from app.services.directory_cache import DirectoryScanCapacityError + + original = sqlite_cache._connection_cache + replacement = {} + root = MagicMock() + replacement_root = MagicMock() + root.replacement.return_value = replacement_root + sqlite_cache._source_root = root + sqlite_cache._source_available.set() + monkeypatch.setattr("app.services.directory_cache.get_smb_connection_cache", lambda _source: replacement) + sqlite_cache._active_workers = 1 + try: + with pytest.raises(DirectoryScanCapacityError): + await sqlite_cache._refresh_connection_generation() + root.replacement.assert_not_called() + assert sqlite_cache._connection_cache is original + finally: + sqlite_cache._active_workers = 0 + await sqlite_cache._refresh_connection_generation() + assert sqlite_cache._connection_cache is replacement + assert sqlite_cache._source_root is replacement_root + assert not sqlite_cache._source_available.is_set() + assert sqlite_cache.coordinator.verification_required + root.replacement.assert_called_once() + + +def test_smb_root_holds_ancestors_and_checks_exact_children(smb_directory_server): + from app.services.directory_traversal import DirectoryTraversalError, SmbDirectoryRoot + + root = SmbDirectoryRoot(r"\\host\share\base") + read = root.read("parent", lambda: None) + try: + assert all(handle.connected for handle in smb_directory_server.handles) + assert [entry.name for entry in read] == ["Exact", "file", "redirect"] + assert read.has_directory_child("Exact") + assert not read.has_directory_child("exact") + assert not read.has_directory_child("file") + assert not read.has_directory_child("missing") + with pytest.raises(DirectoryTraversalError): + read.has_directory_child("redirect") + assert smb_directory_server.patterns.count("*") == 3 + assert set(smb_directory_server.patterns) == {"*", "Exact", "exact", "file", "missing", "redirect"} + finally: + read.close() + assert any(handle.connected for handle in smb_directory_server.handles) + root.close() + assert all(not handle.connected for handle in smb_directory_server.handles) + + +@pytest.mark.parametrize("replacement", ["file", "session", "tree", "name"]) +def test_smb_root_rejects_replaced_bindings(smb_directory_server, replacement): + from uuid import UUID + + from app.services.directory_traversal import DirectoryTraversalError, SmbDirectoryRoot + + root = SmbDirectoryRoot(r"\\host\share\base") + root.read("", lambda: None).close() + if replacement == "file": + smb_directory_server.identifiers["base"] += 1 + elif replacement == "volume": + smb_directory_server.volumes["base"] = 2 + elif replacement == "server": + smb_directory_server.connection.server_guid = UUID(int=2) + elif replacement == "session": + smb_directory_server.tree.session.session_id += 1 + elif replacement == "tree": + smb_directory_server.tree.tree_connect_id += 1 + else: + smb_directory_server.names["base"] = "\\outside" + try: + with pytest.raises(DirectoryTraversalError): + root.read("parent", lambda: None) + finally: + root.close() + + +@pytest.mark.parametrize("unsafe", ["dfs", "redirected_parent"]) +def test_smb_root_refuses_unverified_parent_sources(smb_directory_server, unsafe): + from app.services.directory_traversal import DirectoryTraversalError, SmbDirectoryRoot + + if unsafe == "dfs": + smb_directory_server.tree.is_dfs_share = True + elif unsafe == "zero_id": + smb_directory_server.identifiers["base"] = 0 + elif unsafe == "zero_volume": + smb_directory_server.volumes["base"] = 0 + elif unsafe == "redirected_parent": + smb_directory_server.names["base\\parent"] = "\\outside\\parent" + else: + smb_directory_server.volumes["base\\parent"] = 2 + with pytest.raises(DirectoryTraversalError): + SmbDirectoryRoot(r"\\host\share\base").read("parent", lambda: None) + + +@pytest.mark.parametrize("file_id", [0, 0xFFFFFFFFFFFFFFFF, 1]) +def test_smb_root_accepts_unavailable_ids_and_inconsistent_volume_metadata(smb_directory_server, file_id): + from app.services.directory_traversal import SmbDirectoryRoot + + smb_directory_server.identifiers["base"] = file_id + smb_directory_server.volumes["base"] = 0 + smb_directory_server.volumes["base\\parent"] = 2 + smb_directory_server.tree.session.session_id = 0 + smb_directory_server.tree.tree_connect_id = 0 + root = SmbDirectoryRoot(r"\\host\share\base") + try: + read = root.read("parent", lambda: None) + try: + assert [entry.name for entry in read] == ["Exact", "file", "redirect"] + smb_directory_server.volumes["base\\parent"] += 1 + read.verify() + finally: + read.close() + finally: + root.close() + + +def test_smb_optional_volume_query_does_not_prevent_safe_traversal(smb_directory_server): + from app.services.directory_traversal import SmbDirectoryRoot + + smb_directory_server.volume_error = _smb_response_error(NtStatus.STATUS_NOT_SUPPORTED) + root = SmbDirectoryRoot(r"\\host\share\base") + try: + read = root.read("parent", lambda: None) + try: + assert [entry.name for entry in read] == ["Exact", "file", "redirect"] + read.verify() + assert smb_directory_server.volume_queries == 1 + finally: + read.close() + finally: + root.close() + + +@pytest.mark.parametrize("lookup", [False, True]) +def test_smb_native_read_checks_cancellation_after_protocol_calls(smb_directory_server, lookup): + from app.services.directory_traversal import SmbDirectoryRoot + + cancelled = False + + def check(): + if cancelled: + raise InterruptedError("Injected cancellation.") + + def cancel(): + nonlocal cancelled + cancelled = True + + root = SmbDirectoryRoot(r"\\host\share\base") + read = root.read("parent", check) + smb_directory_server.on_query = cancel + try: + with pytest.raises(InterruptedError): + if lookup: + read.has_directory_child("Exact") + else: + next(read) + finally: + read.close() + root.close() + + +@pytest.mark.parametrize("replaced", [False, True]) +def test_idle_source_queries_wait_for_handle_bound_revalidation(sqlite_cache, smb_directory_server, replaced): + from app.core.directory_index_contract import DIRECTORY_INDEX_CONTRACT + from app.services.directory_cache import DirectorySourceUnavailableError, SQLiteConnectionDirectoryCache + from app.services.directory_traversal import SmbDirectoryRoot + + cache = sqlite_cache + cache._source_root = SmbDirectoryRoot(r"\\host\share\base") + cache._open_source_read = SQLiteConnectionDirectoryCache._open_source_read.__get__(cache) + cache.index.write_paths(["parent/Exact"]) + assert cache.index.finish_generation(1) + cache._probe_source_root() + accepted = cache.index.search("Exact") + token = cache.coordinator.begin_read("parent") + try: + cache._last_access -= DIRECTORY_INDEX_CONTRACT.limits["idle_release_seconds"] + if replaced: + smb_directory_server.identifiers["base"] += 1 + with pytest.raises(DirectorySourceUnavailableError): + cache.search_index("Exact") + assert not cache.coordinator.is_current(token) + assert cache.coordinator.verification_required + assert cache.index.search("Exact") == accepted + if replaced: + with pytest.raises(DirectorySourceUnavailableError): + cache._probe_source_root() + cache._probe_source_root() + assert cache.search_index("Exact") == accepted + assert cache.index.search("Exact") == accepted + finally: + cache.coordinator.end_read(token) + cache._source_root.close() + + +def test_smb_reconciliation_worker_prunes_missing_subtrees_and_discovers_children(sqlite_cache, smb_directory_server): + from app.services.directory_cache import SQLiteConnectionDirectoryCache + from app.services.directory_traversal import SmbDirectoryRoot + + sqlite_cache._source_root = SmbDirectoryRoot(r"\\host\share\base") + sqlite_cache._open_source_read = SQLiteConnectionDirectoryCache._open_source_read.__get__(sqlite_cache) + smb_directory_server.directories["base\\parent"] = [("Exact", 16), ("file", 0), ("New", 16)] + smb_directory_server.directories["base\\parent\\New"] = [("child", 16)] + smb_directory_server.directories["base\\parent\\New\\child"] = [] + sqlite_cache.index.write_paths(["parent", "parent/Exact", "parent/old", "parent/old/leaf", "outside"]) + assert sqlite_cache.index.finish_generation(1) + before = sqlite_cache.index.search("") + sqlite_cache.index.queue_reconciliation_parent(1, "parent") + sqlite_cache._reconcile_disk_queue() + assert sqlite_cache.index.reconciliation_generation() is None + assert sqlite_cache.index.search("old").total_matches == 0 + assert sqlite_cache.index.search("New/child").total_matches == 1 + assert sqlite_cache.index.search("Exact").total_matches == 1 + assert sqlite_cache.index.search("outside").total_matches == 1 + assert sqlite_cache.index.search("file").total_matches == 0 + assert sqlite_cache.index.search("").last_verified_at_ms == before.last_verified_at_ms + assert not sqlite_cache._scan_running.is_set() + assert not sqlite_cache.coordinator._producers + sqlite_cache._request_stop() + sqlite_cache._finish_stop() + assert all(not handle.connected for handle in smb_directory_server.handles) + + +@pytest.fixture +def smb_source_cache(sqlite_cache, smb_directory_server): + from app.services.directory_cache import SQLiteConnectionDirectoryCache + from app.services.directory_traversal import SmbDirectoryRoot + + sqlite_cache._source_root = SmbDirectoryRoot(r"\\host\share\base") + sqlite_cache._open_source_read = SQLiteConnectionDirectoryCache._open_source_read.__get__(sqlite_cache) + try: + yield sqlite_cache + finally: + sqlite_cache._request_stop() + sqlite_cache._finish_stop() + + +def test_reconnected_candidate_revalidates_completed_branches(smb_source_cache, smb_directory_server, monkeypatch): + cache = smb_source_cache + server = smb_directory_server + server.directories["base"] = [("old", 16), ("later", 16)] + server.directories["base\\old"] = [] + server.directories["base\\later"] = [] + cache.index.write_paths(["accepted"]) + assert cache.index.finish_generation(1) + scan_parent = cache._scan_parent + + def interrupt(generation, parent, **kwargs): + if parent == "later": + raise ConnectionError("Injected disconnection after a completed branch.") + return scan_parent(generation, parent, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(cache, "_scan_parent", interrupt) + with pytest.raises(ConnectionError): + cache._scan_disk_queue() + candidate, selected = cache.index.prepare_scan() + assert not selected + assert cache.index.pending_scan_paths(candidate) == ["later"] + server.directories["base"] = [("later", 16), ("new", 16)] + del server.directories["base\\old"] + server.directories["base\\new"] = [] + server.tree.session.session_id += 1 + cache.coordinator.invalidate() + cache._source_root = cache._source_root.replacement() + cache._probe_source_root() + cache._scan_disk_queue() + result = cache.index.search("old new later") + assert {row.path for row in result.results} == {"new", "later"} + assert result.coverage == "complete" + assert result.last_verified_at_ms is not None + assert cache.index._connection().execute("SELECT selected_generation FROM source_metadata").fetchone()[0] == candidate + + +@pytest.mark.parametrize("reconcile", [False, True]) +@pytest.mark.parametrize("parent", ["parent", "parent/old"]) +@pytest.mark.parametrize("missing_status", [None, NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, NtStatus.STATUS_NOT_A_DIRECTORY]) +def test_missing_queued_smb_parent_recovers_without_watcher_hint( + smb_source_cache, smb_directory_server, monkeypatch, reconcile, parent, missing_status +): + from app.services import directory_traversal + + sqlite_cache = smb_source_cache + server = smb_directory_server + sqlite_cache.index.write_paths(["parent", "parent/old", "outside"]) + assert sqlite_cache.index.finish_generation(1) + before = sqlite_cache.index.status() + sqlite_cache._probe_source_root() + sqlite_cache.index.queue_reconciliation_parent(1, parent) + server.directories["base"] = [("outside", 16)] + server.directories["base\\outside"] = [] + del server.directories["base\\parent"] + original_create = directory_traversal.Open.create + + def create(handle, *args): + if missing_status is not None and handle.path.startswith("base\\parent"): + raise _smb_response_error(missing_status) + return original_create(handle, *args) + + monkeypatch.setattr(directory_traversal.Open, "create", create) + if reconcile: + sqlite_cache._reconcile_disk_queue() + else: + sqlite_cache._scan_disk_queue() + after = sqlite_cache.index.search("parent outside") + assert [row.path for row in after.results] == ["outside"] + assert sqlite_cache.index.reconciliation_generation() is None + assert sqlite_cache._source_available.is_set() + assert not sqlite_cache.coordinator._producers + assert sqlite_cache._failure_retry_delay == 0 + if reconcile: + assert after.last_verified_at_ms == before.last_verified_at_ms + sqlite_cache._scan_disk_queue() + assert sqlite_cache.index.status().coverage == "complete" + + +def test_missing_smb_parent_recovery_does_not_hide_storage_failure(smb_source_cache, smb_directory_server, monkeypatch): + sqlite_cache = smb_source_cache + smb_directory_server.directories["base\\parent"] = [] + sqlite_cache.index.write_paths(["parent", "parent/old"]) + assert sqlite_cache.index.finish_generation(1) + sqlite_cache.index.queue_reconciliation_parent(1, "parent") + before = sqlite_cache.index.status() + original = FileNotFoundError("Injected local index failure.") + monkeypatch.setattr(sqlite_cache, "_prune_missing_children", Mock(side_effect=original)) + with pytest.raises(FileNotFoundError) as caught: + sqlite_cache._reconcile_disk_queue() + assert caught.value is original + assert sqlite_cache.index.status() == before + assert sqlite_cache.index.pending_scan_paths(1) == ["parent"] + + +def test_missing_smb_parent_recovery_rejects_stale_ancestor(smb_source_cache, smb_directory_server, monkeypatch): + from app.services.directory_cache import ScanObservationInvalidated + from app.services.directory_traversal import SmbDirectoryRead + + sqlite_cache = smb_source_cache + sqlite_cache.index.write_paths(["parent", "parent/old"]) + assert sqlite_cache.index.finish_generation(1) + sqlite_cache.index.queue_reconciliation_parent(1, "parent") + smb_directory_server.directories["base"] = [] + del smb_directory_server.directories["base\\parent"] + before = sqlite_cache.index.status() + lookup = SmbDirectoryRead.has_directory_child + + def invalidate(read, name): + result = lookup(read, name) + sqlite_cache.coordinator.invalidate() + return result + + monkeypatch.setattr(SmbDirectoryRead, "has_directory_child", invalidate) + with pytest.raises(ScanObservationInvalidated): + sqlite_cache._reconcile_disk_queue() + assert sqlite_cache.index.status() == before + assert sqlite_cache.index.pending_scan_paths(1) == ["parent"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("operation", ["delete", "copy"]) +@pytest.mark.parametrize("failed", [False, True]) +async def test_recursive_smb_mutations_reconcile_partial_results(sqlite_cache, smb_directory_server, monkeypatch, failed, operation): + import stat + from contextlib import asynccontextmanager + from pathlib import PureWindowsPath + + from smbclient._os import FileAttributes + + from app.services.directory_cache import SQLiteConnectionDirectoryCache + from app.services.directory_traversal import SmbDirectoryRoot + from app.storage.smb import SMBBackend + + directory_attributes = FileAttributes.FILE_ATTRIBUTE_DIRECTORY + server = smb_directory_server + server.tree.share_name = r"\\fixture\share" + server.directories.update( + { + "base\\parent": [("target", directory_attributes)], + "base\\parent\\target": [("removed", directory_attributes), ("protected", directory_attributes)], + "base\\parent\\target\\removed": [], + "base\\parent\\target\\protected": [], + } + ) + backend = SMBBackend( + host="fixture", share_name="share", username="user", password="password", connection_context_key="fixture", path_prefix="base" + ) + sqlite_cache._path_prefix = "base" + sqlite_cache._smb_base_path = backend._build_smb_path("") + sqlite_cache._source_root = SmbDirectoryRoot(sqlite_cache._smb_base_path) + sqlite_cache._open_source_read = SQLiteConnectionDirectoryCache._open_source_read.__get__(sqlite_cache) + index = sqlite_cache.index + index.write_paths(["parent", "parent/target", "parent/target/removed", "parent/target/protected", "outside"]) + assert index.finish_generation(1) + before = index.search("") + manager = SimpleNamespace(get_cache=lambda _connection_id: sqlite_cache) + monkeypatch.setattr("app.services.directory_cache.get_directory_cache_manager", lambda: manager) + + @asynccontextmanager + async def connected(**kwargs): + yield + + async def pool(): + return SimpleNamespace(get_connection=connected, connection_diagnostics=lambda _cache: {}, generation_aborting=lambda _cache: False) + + def scan(path, **kwargs): + relative = "\\".join(PureWindowsPath(path).parts[1:]) + for name, _attributes in server.directories[relative]: + yield SimpleNamespace(name=name, path=str(PureWindowsPath(path) / name)) + + def remove(path, **kwargs): + relative = "\\".join(PureWindowsPath(path).parts[1:]) + if failed and relative.endswith("\\protected"): + raise PermissionError("Injected descendant deletion denial.") + parent, _, name = relative.rpartition("\\") + server.directories[parent] = [(child, attributes) for child, attributes in server.directories[parent] if child != name] + del server.directories[relative] + + def create(path, **kwargs): + relative = "\\".join(PureWindowsPath(path).parts[1:]) + if failed and relative.endswith("\\protected"): + raise PermissionError("Injected descendant copy denial.") + parent, _, name = relative.rpartition("\\") + server.directories[parent].append((name, directory_attributes)) + server.directories[relative] = [] + + monkeypatch.setattr("app.storage.smb.get_connection_pool", pool) + monkeypatch.setattr( + "app.storage.smb.smbclient.stat", lambda *args, **kwargs: SimpleNamespace(st_mode=stat.S_IFDIR, st_atime=0, st_mtime=0) + ) + monkeypatch.setattr("app.storage.smb.smbclient.scandir", scan) + monkeypatch.setattr("app.storage.smb.smbclient.rmdir", remove) + monkeypatch.setattr("app.storage.smb.smbclient.mkdir", create) + monkeypatch.setattr("app.storage.smb.smbclient.path.exists", lambda *args, **kwargs: False) + monkeypatch.setattr("app.storage.smb.smbclient.utime", lambda *args, **kwargs: None) + try: + mutation = backend.delete_item("parent/target") if operation == "delete" else backend.copy_item("parent/target", "parent/copied") + if failed: + with pytest.raises(PermissionError, match="Injected descendant"): + await mutation + else: + await mutation + assert ("base\\parent\\target\\removed" in server.directories) == (operation == "copy") + assert index.search("removed").total_matches == 1 + sqlite_cache.coordinator.apply_pending() + sqlite_cache._reconcile_disk_queue() + after = sqlite_cache.search_index("parent outside") + expected = {"parent", "outside"} + if operation == "copy": + expected.update({"parent/target", "parent/target/removed", "parent/target/protected", "parent/copied", "parent/copied/removed"}) + if not failed: + expected.add("parent/copied/protected") + elif failed: + expected.update({"parent/target", "parent/target/protected"}) + assert {row.path for row in after.results} == expected + assert after.total_matches == after.directory_count == len(expected) + assert after.last_verified_at_ms == before.last_verified_at_ms + assert index.accepted_generation() == 1 + assert index.reconciliation_generation() is None + assert sqlite_cache.coordinator.verification_required + assert not sqlite_cache.coordinator._producers + assert sqlite_cache.resources.usage().reserved_bytes == 0 + finally: + sqlite_cache._request_stop() + sqlite_cache._finish_stop() + + +def test_smb_worker_root_replacement_hides_results_without_deleting_accepted_rows(sqlite_cache, smb_directory_server): + from app.services.directory_cache import DirectorySourceUnavailableError, SQLiteConnectionDirectoryCache + from app.services.directory_traversal import SmbDirectoryRoot + + sqlite_cache._source_root = SmbDirectoryRoot(r"\\host\share\base") + sqlite_cache._open_source_read = SQLiteConnectionDirectoryCache._open_source_read.__get__(sqlite_cache) + sqlite_cache.index.write_paths(["parent", "parent/old", "parent/old/leaf"]) + assert sqlite_cache.index.finish_generation(1) + sqlite_cache._probe_source_root() + before = sqlite_cache.index.search("parent") + sqlite_cache.index.queue_reconciliation_parent(1, "parent") + smb_directory_server.identifiers["base"] += 1 + with pytest.raises(DirectorySourceUnavailableError, match="temporarily unavailable"): + sqlite_cache._reconcile_disk_queue() + assert sqlite_cache.index.search("parent") == before + assert sqlite_cache.index.reconciliation_generation() == 1 + with pytest.raises(DirectorySourceUnavailableError): + sqlite_cache.search_index("parent") + assert sqlite_cache.coordinator.verification_required + assert not sqlite_cache._source_available.is_set() + assert not sqlite_cache._scan_running.is_set() + assert not sqlite_cache.coordinator._producers + sqlite_cache._request_stop() + sqlite_cache._finish_stop() + + +def test_smb_pruning_rejects_event_after_native_absence_check(sqlite_cache, smb_directory_server, monkeypatch): + from app.services.directory_cache import ScanObservationInvalidated, SQLiteConnectionDirectoryCache + from app.services.directory_traversal import SmbDirectoryRead, SmbDirectoryRoot + + sqlite_cache._source_root = SmbDirectoryRoot(r"\\host\share\base") + sqlite_cache._open_source_read = SQLiteConnectionDirectoryCache._open_source_read.__get__(sqlite_cache) + smb_directory_server.directories["base\\parent"] = [("Exact", 16)] + sqlite_cache.index.write_paths(["parent", "parent/Exact", "parent/old", "parent/old/leaf"]) + assert sqlite_cache.index.finish_generation(1) + sqlite_cache.index.queue_reconciliation_parent(1, "parent") + before = sqlite_cache.index.search("parent") + original = SmbDirectoryRead.has_directory_child + + def lookup(read, name): + present = original(read, name) + if not present: + sqlite_cache.coordinator.invalidate() + return present + + monkeypatch.setattr(SmbDirectoryRead, "has_directory_child", lookup) + with pytest.raises(ScanObservationInvalidated): + sqlite_cache._reconcile_disk_queue() + assert sqlite_cache.index.search("parent") == before + assert sqlite_cache.index.reconciliation_generation() == 1 + assert sqlite_cache.coordinator.verification_required + assert not sqlite_cache.coordinator._producers + assert not sqlite_cache._scan_running.is_set() + sqlite_cache._request_stop() + sqlite_cache._finish_stop() + + +@pytest.mark.parametrize("primary_failure", [False, True]) +def test_smb_worker_closes_all_ancestors_and_preserves_primary_failure(sqlite_cache, smb_directory_server, monkeypatch, primary_failure): + from smbprotocol.exceptions import PathNotCovered + + from app.services import directory_traversal + from app.services.directory_cache import SQLiteConnectionDirectoryCache + from app.services.directory_traversal import SmbDirectoryRoot + + sqlite_cache._source_root = SmbDirectoryRoot(r"\\host\share\base") + sqlite_cache._open_source_read = SQLiteConnectionDirectoryCache._open_source_read.__get__(sqlite_cache) + smb_directory_server.directories["base\\parent"] = [] + sqlite_cache.index.write_paths(["parent", "parent/old"]) + assert sqlite_cache.index.finish_generation(1) + sqlite_cache.index.queue_reconciliation_parent(1, "parent") + failure = _smb_response_error(NtStatus.STATUS_PATH_NOT_COVERED) + handle_type = directory_traversal.Open + original_close = handle_type.close + + def close(handle): + original_close(handle) + if handle.path == "base\\parent": + raise OSError("Injected close failure.") + + def query(): + if primary_failure: + raise failure + + monkeypatch.setattr(handle_type, "close", close) + smb_directory_server.on_query = query + with pytest.raises(PathNotCovered if primary_failure else OSError) as caught: + sqlite_cache._reconcile_disk_queue() + if primary_failure: + assert caught.value is failure + assert sqlite_cache.index.search("old").total_matches == 1 + assert sqlite_cache.index.reconciliation_generation() == 1 + assert not sqlite_cache.coordinator._producers + assert not sqlite_cache._scan_running.is_set() + sqlite_cache._request_stop() + sqlite_cache._finish_stop() + + +def test_smb_full_worker_reconciles_through_capture_and_retains_later_events(sqlite_cache, smb_directory_server, monkeypatch): + from app.services.directory_cache import SQLiteConnectionDirectoryCache + from app.services.directory_traversal import SmbDirectoryRoot + + sqlite_cache._source_root = SmbDirectoryRoot(r"\\host\share\base") + sqlite_cache._open_source_read = SQLiteConnectionDirectoryCache._open_source_read.__get__(sqlite_cache) + smb_directory_server.directories.update( + {"base": [("old", 16), ("stable", 16)], "base\\old": [("leaf", 16)], "base\\old\\leaf": [], "base\\stable": []} + ) + sqlite_cache.index.write_paths(["old", "old/leaf", "stable"]) + assert sqlite_cache.index.finish_generation(1) + original_scan = sqlite_cache._scan_parent + original_accept = sqlite_cache.coordinator.accept_generation + renamed = False + captured = [] + + def scan(generation, parent, *, reconcile=False): + nonlocal renamed + original_scan(generation, parent, reconcile=reconcile) + if not parent and not reconcile and not renamed: + renamed = True + smb_directory_server.directories["base"] = [("new", 16), ("stable", 16)] + smb_directory_server.directories["base\\new"] = [("leaf", 16)] + smb_directory_server.directories["base\\new\\leaf"] = [] + del smb_directory_server.directories["base\\old"] + del smb_directory_server.directories["base\\old\\leaf"] + sqlite_cache.observe_parent("") + + def accept(baseline, generation, **kwargs): + assert not sqlite_cache.coordinator.is_current(baseline) + assert sqlite_cache.index.reconciliation_generation() is None + assert not sqlite_cache.index.pending_scan_paths(generation) + assert not sqlite_cache.coordinator.pending + accepted = original_accept(baseline, generation, **kwargs) + captured.append(accepted) + sqlite_cache.observe_parent("") + return accepted + + monkeypatch.setattr(sqlite_cache, "_scan_parent", scan) + monkeypatch.setattr(sqlite_cache.coordinator, "accept_generation", accept) + sqlite_cache._scan_disk_queue() + assert captured == [True] + assert sqlite_cache.index.accepted_generation() != 1 + assert sqlite_cache.index.search("old").total_matches == 0 + assert sqlite_cache.index.search("new/leaf").total_matches == 1 + assert sqlite_cache.index.search("stable").total_matches == 1 + assert sqlite_cache.index.search("").coverage == "complete" + assert len(sqlite_cache.coordinator.pending) == 1 + assert sqlite_cache.coordinator.verification_required + assert not sqlite_cache.coordinator._producers + assert not sqlite_cache._scan_running.is_set() + sqlite_cache._request_stop() + sqlite_cache._finish_stop() + assert all(not handle.connected for handle in smb_directory_server.handles) + + +@pytest.mark.parametrize("refusal", ["epoch", "completed", "uncertain", "pending", "producer", "accepted_work", "candidate_work"]) +def test_backend_capture_refusal_preserves_selected_rows_and_freshness(sqlite_cache, refusal): + from app.services.directory_cache import ScanObservationInvalidated + + sqlite_cache.index.write_paths(["accepted", "accepted/child"]) + assert sqlite_cache.index.finish_generation(1) + generation, _selected = sqlite_cache.index.prepare_scan() + sqlite_cache.index.seed_scan(generation) + sqlite_cache.index.write_scan_children(generation, [("candidate", False)]) + sqlite_cache.index.finish_scan_parent(generation, "") + before = sqlite_cache.index.search("accepted") + with sqlite_cache.coordinator.read("") as baseline: + if refusal == "epoch": + sqlite_cache.coordinator.invalidate() + elif refusal == "completed": + sqlite_cache.coordinator._producers.pop(baseline.producer_id) + elif refusal == "uncertain": + sqlite_cache.coordinator._uncertain_pending = True + elif refusal == "pending": + sqlite_cache.observe_parent("") + elif refusal == "accepted_work": + sqlite_cache.index.queue_reconciliation_parent(1, "") + elif refusal == "candidate_work": + sqlite_cache.index.write_scan_children(generation, [("pending", True)]) + if refusal == "producer": + with sqlite_cache.coordinator.read("accepted"): + with pytest.raises(ScanObservationInvalidated): + sqlite_cache.coordinator.accept_generation(baseline, generation) + else: + with pytest.raises(ScanObservationInvalidated): + sqlite_cache.coordinator.accept_generation(baseline, generation) + assert sqlite_cache.index.search("accepted") == before + assert sqlite_cache.index.accepted_generation() == 1 + assert sqlite_cache.coordinator.verification_required + assert not sqlite_cache.coordinator._producers + + +def test_backend_source_catalogues_use_boundary_settings_not_runtime_or_credentials(): + from app.services.directory_cache import SQLiteConnectionDirectoryCache + + first = SQLiteConnectionDirectoryCache("fixture", "fixture", "share", "user", "password") + second = SQLiteConnectionDirectoryCache("fixture", "fixture", "share", "user", "changed-password", source_revision="changed-time") + try: + assert first._persist_path.parent == second._persist_path.parent + assert first._persist_path == second._persist_path + assert first._source_identity == second._source_identity + assert first._diagnostic_generation != second._diagnostic_generation + assert "runtime_session" not in first._source_identity + assert "revision" not in first._source_identity + assert first._source_root._binding is None + assert second._source_root._binding is None + assert first.search_index("retained").directory_count == 0 + assert second.search_index("retained").directory_count == 0 + finally: + first._request_stop() + second._request_stop() + first._finish_stop() + second._finish_stop() + + +@pytest.mark.parametrize("name", ["bad/name", "bad\\name", "bad:name", "trailing.", "\ud800", "\udc00", "nul\x00name"]) +def test_smb_unsupported_directory_names_preserve_readable_root_coverage(sqlite_cache, smb_directory_server, name): + from app.services.directory_cache import SQLiteConnectionDirectoryCache + from app.services.directory_traversal import SmbDirectoryRoot + + sqlite_cache._source_root = SmbDirectoryRoot(r"\\host\share\base") + sqlite_cache._open_source_read = SQLiteConnectionDirectoryCache._open_source_read.__get__(sqlite_cache) + smb_directory_server.directories["base"] = [(name, 16), ("\ud800", 0), ("readable", 16)] + smb_directory_server.directories["base\\readable"] = [] + sqlite_cache._scan_disk_queue() + response = sqlite_cache.search_index("readable") + assert response.total_matches == 1 + assert response.directory_count == 1 + assert response.coverage == "partial" + assert response.issue_count == 1 + assert sqlite_cache._source_available.is_set() + assert sqlite_cache.coordinator.verification_required + assert not sqlite_cache.coordinator._producers + sqlite_cache._request_stop() + sqlite_cache._finish_stop() + + +def test_smb_many_file_traversal_checks_control_before_directory_filtering(smb_directory_server): + from app.services.directory_traversal import SmbDirectoryRoot + + smb_directory_server.directories["base"] = [(f"file-{position}", 0) for position in range(513)] + [("last-directory", 16)] + checks = 0 + stop_at = None + + def check(): + nonlocal checks + checks += 1 + if stop_at is not None and checks >= stop_at: + raise InterruptedError("Injected many-file cancellation.") + + root = SmbDirectoryRoot(r"\\host\share\base") + read = root.read("", check) + try: + stop_at = checks + 20 + with pytest.raises(InterruptedError): + list(read) + finally: + read.close() + try: + stop_at = None + read = root.read("", check) + try: + entries = list(read) + assert len(entries) == 514 + assert entries[-1].name == "last-directory" + finally: + read.close() + finally: + root.close() + + +def test_smb_native_access_denial_continues_readable_siblings(sqlite_cache, smb_directory_server): + from app.services.directory_cache import SQLiteConnectionDirectoryCache + from app.services.directory_traversal import SmbDirectoryRoot + + sqlite_cache._source_root = SmbDirectoryRoot(r"\\host\share\base") + sqlite_cache._open_source_read = SQLiteConnectionDirectoryCache._open_source_read.__get__(sqlite_cache) + smb_directory_server.directories["base"] = [("parent", 16), ("readable", 16)] + smb_directory_server.directories["base\\readable"] = [] + smb_directory_server.denied.add("base\\parent") + sqlite_cache._scan_disk_queue() + response = sqlite_cache.search_index("readable") + assert response.total_matches == 1 + assert response.directory_count == 2 + assert response.coverage == "partial" + assert response.issue_count == 1 + assert response.last_verified_at_ms is None + sqlite_cache._request_stop() + sqlite_cache._finish_stop() + + +@pytest.mark.parametrize("transport_failure", [False, True]) +def test_smb_held_root_metadata_failure_blocks_search_and_retains_rows(sqlite_cache, smb_directory_server, transport_failure): + from smbprotocol.exceptions import SMBException + + from app.services.directory_cache import DirectorySourceUnavailableError, SQLiteConnectionDirectoryCache + from app.services.directory_traversal import SmbDirectoryRoot + + sqlite_cache._source_root = SmbDirectoryRoot(r"\\host\share\base") + sqlite_cache._open_source_read = SQLiteConnectionDirectoryCache._open_source_read.__get__(sqlite_cache) + sqlite_cache.index.write_paths(["parent", "parent/old"]) + assert sqlite_cache.index.finish_generation(1) + sqlite_cache._probe_source_root() + before = sqlite_cache.index.search("parent") + sqlite_cache.index.queue_reconciliation_parent(1, "parent") + smb_directory_server.metadata_denied.add("base") + if transport_failure: + smb_directory_server.metadata_error = SMBException("Injected disconnected transport.") + with pytest.raises(DirectorySourceUnavailableError): + sqlite_cache._reconcile_disk_queue() + assert sqlite_cache.index.search("parent") == before + with pytest.raises(DirectorySourceUnavailableError): + sqlite_cache.search_index("parent") + sqlite_cache._request_stop() + sqlite_cache._finish_stop() + + +def test_smb_root_handles_and_database_lease_remain_owned_through_blocked_worker(sqlite_cache, smb_directory_server): + import concurrent.futures + import threading + + from app.services.directory_cache import SQLiteConnectionDirectoryCache + from app.services.directory_traversal import SmbDirectoryRoot + + sqlite_cache._source_root = SmbDirectoryRoot(r"\\host\share\base") + sqlite_cache._open_source_read = SQLiteConnectionDirectoryCache._open_source_read.__get__(sqlite_cache) + sqlite_cache.index.write_paths(["parent", "parent/old"]) + assert sqlite_cache.index.finish_generation(1) + sqlite_cache.index.queue_reconciliation_parent(1, "parent") + entered = threading.Event() + release = threading.Event() + + def blocked_query(): + entered.set() + assert release.wait(5) + + def worker(): + with sqlite_cache._workers: + sqlite_cache._active_workers += 1 + try: + sqlite_cache._reconcile_disk_queue() + finally: + with sqlite_cache._workers: + sqlite_cache._active_workers -= 1 + sqlite_cache._workers.notify_all() + + smb_directory_server.on_query = blocked_query + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: + future = executor.submit(worker) + assert entered.wait(5) + sqlite_cache._request_stop() + stopped = executor.submit(sqlite_cache._finish_stop) + try: + assert not stopped.done() + assert sqlite_cache._source_root._held_handles + assert all(not handle.closed for handle in sqlite_cache._source_root._held_handles) + assert sqlite_cache._opened.is_set() + assert sqlite_cache._scan_running.is_set() + assert not sqlite_cache._manager_scan_slots.acquire(blocking=False) + assert sqlite_cache.coordinator._producers + finally: + release.set() + with pytest.raises(InterruptedError): + future.result(timeout=5) + stopped.result(timeout=5) + assert not sqlite_cache._source_root._held_handles + assert not sqlite_cache._opened.is_set() + assert not sqlite_cache.coordinator._producers + assert sqlite_cache._manager_scan_slots.acquire(blocking=False) + sqlite_cache._manager_scan_slots.release() + + +def test_root_read_failure_blocks_retained_results_but_descendant_failure_does_not(sqlite_cache, monkeypatch): + from app.services.directory_cache import DirectorySourceUnavailableError + + sqlite_cache.index.write_paths(["accepted"]) + + def missing(path, **kwargs): + if path.endswith("\\descendant"): + raise FileNotFoundError("Injected missing source descendant.") + return (entry for entry in ()) + + monkeypatch.setattr("smbclient.scandir", missing) + with pytest.raises(FileNotFoundError): + sqlite_cache._scan_parent(1, "descendant") + assert sqlite_cache.search_index("accepted").total_matches == 1 + monkeypatch.setattr("smbclient.scandir", Mock(side_effect=FileNotFoundError("Injected missing source root."))) + with pytest.raises(DirectorySourceUnavailableError, match="root is unavailable"): + sqlite_cache._scan_parent(1, "") + with pytest.raises(DirectorySourceUnavailableError, match="root is unavailable"): + sqlite_cache.search_index("accepted") + assert sqlite_cache.index.search("accepted").total_matches == 1 + + +def test_unprobed_root_never_exposes_retained_rows_and_empty_probe_recovers(sqlite_cache, monkeypatch): + sqlite_cache.index.write_paths(["accepted"]) + sqlite_cache._source_available.clear() + sqlite_cache._source_probe_completed.clear() + response = sqlite_cache.search_index("accepted") + assert not response.results + assert response.coverage == "partial" + monkeypatch.setattr("smbclient.scandir", lambda *args, **kwargs: (entry for entry in [])) + sqlite_cache._probe_source_root() + assert sqlite_cache.search_index("accepted").total_matches == 1 + + +def test_root_probe_does_not_start_when_scan_capacity_is_owned(sqlite_cache, monkeypatch): + from app.services.directory_cache import DirectoryScanCapacityError + + def unexpected_read(*args, **kwargs): + raise AssertionError("An unadmitted root probe must not access SMB.") + + monkeypatch.setattr("smbclient.scandir", unexpected_read) + assert sqlite_cache._manager_scan_slots.acquire(blocking=False) + try: + with pytest.raises(DirectoryScanCapacityError, match="capacity is in use"): + sqlite_cache._probe_source_root() + assert sqlite_cache._source_available.is_set() + assert not sqlite_cache._scan_running.is_set() + finally: + sqlite_cache._manager_scan_slots.release() + + +def test_invalidated_root_probe_cannot_restore_availability(sqlite_cache, monkeypatch): + from app.services.directory_cache import ScanObservationInvalidated + + sqlite_cache._source_available.clear() + + def entries(*args, **kwargs): + sqlite_cache.coordinator.invalidate() + return + yield + + monkeypatch.setattr("smbclient.scandir", entries) + with pytest.raises(ScanObservationInvalidated, match="root observation became stale"): + sqlite_cache._probe_source_root() + assert not sqlite_cache._source_available.is_set() + + +@pytest.mark.parametrize( + ("maximum_pending", "maximum_bytes", "drain_first"), + [(1, 64, False), (4, 14, False), (1, 64, True), (4, 14, True)], + ids=["queue-entries", "queue-bytes", "barrier-entries", "barrier-bytes"], +) +def test_blocked_root_read_retains_producer_and_permit_after_overflow( + sqlite_cache, monkeypatch, maximum_pending, maximum_bytes, drain_first +): + import threading + + from smbprotocol.change_notify import FileAction + + from app.services.directory_cache import DirectoryScanCoordinator, ScanObservationInvalidated, _DirectoryMutation + + sqlite_cache.index.write_paths(["accepted"]) + assert sqlite_cache.index.finish_generation(1) + verified = sqlite_cache.index.status().last_verified_at_ms + candidate = sqlite_cache.index.begin_verification() + sqlite_cache.index.write_paths(["candidate"], generation_id=candidate) + before = sqlite_cache.index.search("accepted candidate stale") + sqlite_cache.coordinator = DirectoryScanCoordinator(sqlite_cache.index, maximum_pending=maximum_pending, maximum_bytes=maximum_bytes) + sqlite_cache._source_available.clear() + entered = threading.Event() + release = threading.Event() + failures = [] + + def entries(*args, **kwargs): + entered.set() + assert release.wait(timeout=5) + return + yield + + def probe(): + try: + sqlite_cache._probe_source_root() + except BaseException as error: + failures.append(error) + + monkeypatch.setattr("smbclient.scandir", entries) + worker = threading.Thread(target=probe) + worker.start() + try: + assert entered.wait(timeout=5) + coordinator = sqlite_cache.coordinator + token = next(iter(coordinator._producers.values())) + assert coordinator.enqueue(_DirectoryMutation(FileAction.FILE_ACTION_ADDED, "accepted")) + if drain_first: + coordinator.apply_pending() + assert coordinator.enqueue(_DirectoryMutation(FileAction.FILE_ACTION_REMOVED, "deleted")) == drain_first + assert coordinator.epoch == 1 + assert not coordinator.barriers + assert coordinator.barrier_bytes == 0 + assert len(coordinator.pending) == 1 + assert coordinator.pending_bytes == len("deleted" if drain_first else "accepted") + assert not coordinator.enqueue(_DirectoryMutation(FileAction.FILE_ACTION_ADDED, "overflow")) + assert coordinator.epoch == 2 + assert len(coordinator.pending) == 1 + assert coordinator.pending_bytes == len("deleted" if drain_first else "accepted") + assert len(coordinator._producers) == 1 + assert coordinator.verification_required + assert not sqlite_cache._manager_scan_slots.acquire(blocking=False) + with pytest.raises(ScanObservationInvalidated): + coordinator.commit_children(token, candidate, [("stale", False)]) + with pytest.raises(ScanObservationInvalidated): + coordinator.finish_parent(token, candidate) + with pytest.raises(ScanObservationInvalidated): + coordinator.accept_generation(token, candidate) + assert coordinator.apply_pending() == 1 + assert not coordinator.pending + assert coordinator.pending_bytes == 0 + response = sqlite_cache.index.search("accepted candidate stale") + assert response.total_matches == response.directory_count == 1 + assert response.results == before.results + assert response.coverage == "uncertain" + assert response.issue_count == 1 + assert response.last_verified_at_ms == verified + finally: + release.set() + worker.join(timeout=5) + assert not worker.is_alive() + assert len(failures) == 1 + assert isinstance(failures[0], ScanObservationInvalidated) + assert not sqlite_cache.coordinator._producers + assert not sqlite_cache._source_available.is_set() + assert sqlite_cache._manager_scan_slots.acquire(blocking=False) + sqlite_cache._manager_scan_slots.release() + + +@pytest.mark.parametrize("stop", [False, True]) +def test_blocked_root_close_retains_producer_and_permit_without_late_availability(sqlite_cache, monkeypatch, stop): + from app.services.directory_cache import ScanObservationInvalidated + + sqlite_cache._source_available.clear() + sqlite_cache.index.write_paths(["accepted"]) + entered = threading.Event() + release = threading.Event() + failures = [] + + class Entries: + def __next__(self): + raise StopIteration + + def close(self): + entered.set() + assert release.wait(timeout=5) + + def probe(): + try: + sqlite_cache._probe_source_root() + except BaseException as error: + failures.append(error) + + monkeypatch.setattr("smbclient.scandir", lambda *args, **kwargs: Entries()) + worker = threading.Thread(target=probe) + worker.start() + try: + assert entered.wait(timeout=5) + if stop: + sqlite_cache._stop_event.set() + else: + sqlite_cache.coordinator.invalidate() + assert len(sqlite_cache.coordinator._producers) == 1 + assert sqlite_cache._scan_running.is_set() + assert not sqlite_cache._source_available.is_set() + assert not sqlite_cache._manager_scan_slots.acquire(blocking=False) + assert sqlite_cache.index.search("accepted").total_matches == 1 + finally: + release.set() + worker.join(timeout=5) + assert not worker.is_alive() + assert len(failures) == 1 + assert isinstance(failures[0], ScanObservationInvalidated) + assert not sqlite_cache.coordinator._producers + assert not sqlite_cache._scan_running.is_set() + assert not sqlite_cache._source_available.is_set() + assert sqlite_cache._manager_scan_slots.acquire(blocking=False) + sqlite_cache._manager_scan_slots.release() + + +def test_root_loss_during_query_blocks_late_results(sqlite_cache, monkeypatch): + from app.services.directory_cache import DirectorySourceUnavailableError + + sqlite_cache.index.write_paths(["accepted"]) + original = sqlite_cache.index.search + + def query(*args, **kwargs): + response = original(*args, **kwargs) + sqlite_cache._source_available.clear() + return response + + monkeypatch.setattr(sqlite_cache.index, "search", query) + with pytest.raises(DirectorySourceUnavailableError, match="root is unavailable"): + sqlite_cache.search_index("accepted") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("reconcile", [False, True]) +async def test_pool_entry_failure_marks_root_unavailable_without_discarding_rows(sqlite_cache, monkeypatch, reconcile): + from contextlib import asynccontextmanager + + sqlite_cache.index.write_paths(["accepted"]) + if reconcile: + assert sqlite_cache.index.finish_generation(1) + sqlite_cache.index.queue_reconciliation_parent(1, "") + + @asynccontextmanager + async def unavailable(**kwargs): + raise OSError("Injected unavailable SMB session.") + yield + + async def pool(): + return SimpleNamespace(get_connection=unavailable, invalidate_connection_cache=AsyncMock()) + + monkeypatch.setattr("app.services.directory_cache.get_connection_pool", pool) + if reconcile: + with pytest.raises(OSError, match="unavailable SMB session"): + await sqlite_cache._run_reconciliation() + else: + await sqlite_cache._run_rescan() + assert not sqlite_cache._source_available.is_set() + assert sqlite_cache._source_probe_completed.is_set() + if not reconcile: + assert "root is unavailable" in sqlite_cache.scan_error + assert sqlite_cache.index.search("accepted").total_matches == 1 + + +@pytest.mark.asyncio +async def test_scan_admission_refusal_preserves_known_root_and_retries(sqlite_cache, monkeypatch): + from contextlib import asynccontextmanager + from time import monotonic + + @asynccontextmanager + async def available(**kwargs): + yield + + async def pool(): + return SimpleNamespace(get_connection=available) + + monkeypatch.setattr("app.services.directory_cache.get_connection_pool", pool) + assert sqlite_cache._manager_scan_slots.acquire(blocking=False) + before = monotonic() + try: + await sqlite_cache._run_rescan() + assert sqlite_cache._source_available.is_set() + assert "capacity is in use" in sqlite_cache.scan_error + assert before < sqlite_cache._resource_retry_at <= monotonic() + sqlite_cache.policy.degraded_verification_seconds + finally: + sqlite_cache._manager_scan_slots.release() + + +@pytest.mark.asyncio +async def test_stale_rescan_preserves_root_and_uses_bounded_observation_retry(sqlite_cache, monkeypatch): + from contextlib import asynccontextmanager + + from app.services.directory_cache import OBSERVATION_RETRY_SECONDS, ScanObservationInvalidated + + @asynccontextmanager + async def connected(**kwargs): + yield + + pool = SimpleNamespace(get_connection=connected, invalidate_connection_cache=AsyncMock()) + monkeypatch.setattr("app.services.directory_cache.get_connection_pool", AsyncMock(return_value=pool)) + monkeypatch.setattr(sqlite_cache, "_refresh_connection_generation", AsyncMock()) + probe = Mock(side_effect=ScanObservationInvalidated("Injected watcher startup.")) + monkeypatch.setattr(sqlite_cache, "_probe_source_root", probe) + clock = SimpleNamespace(monotonic=lambda: 1000.0) + monkeypatch.setattr("app.services.directory_cache.time", clock) + + await sqlite_cache._run_rescan() + assert sqlite_cache._source_available.is_set() + pool.invalidate_connection_cache.assert_not_awaited() + assert sqlite_cache._failure_retry_delay == 0 + assert sqlite_cache._resource_retry_at == 0 + assert sqlite_cache._verification_retry_at == 1000.0 + OBSERVATION_RETRY_SECONDS + assert not sqlite_cache._verification_due(1000.0, now=1000.0) + assert sqlite_cache._verification_due(1000.0, now=sqlite_cache._verification_retry_at) + await sqlite_cache._run_rescan() + assert probe.call_count == 1 + + +@pytest.mark.parametrize("partial", [False, True]) +def test_watcher_startup_uncertainty_is_drained_before_initial_generation(sqlite_cache, monkeypatch, partial): + from smbclient._os import FileAttributes + + def scan(path, **kwargs): + if path == sqlite_cache._smb_base_path: + yield SimpleNamespace(name="visible", smb_info=SimpleNamespace(file_attributes=FileAttributes.FILE_ATTRIBUTE_DIRECTORY)) + if partial: + yield SimpleNamespace( + name="redirect", + smb_info=SimpleNamespace( + file_attributes=FileAttributes.FILE_ATTRIBUTE_DIRECTORY | FileAttributes.FILE_ATTRIBUTE_REPARSE_POINT + ), + ) + + monkeypatch.setattr("smbclient.scandir", scan) + sqlite_cache.coordinator.invalidate() + sqlite_cache._watcher_ready.set() + sqlite_cache._scan_disk_queue() + response = sqlite_cache.index.status() + assert response.directory_count == 1 + assert sqlite_cache.index.search("visible").total_matches == 1 + assert response.coverage == ("partial" if partial else "complete") + assert response.issue_count == int(partial) + assert (response.last_verified_at_ms is None) == partial + assert sqlite_cache.coordinator.verification_required == partial + assert (sqlite_cache.scan_error is not None) == partial + + +@pytest.mark.parametrize("observed", ["empty", "rows", "queue", "issue"]) +def test_startup_event_loss_only_restarts_unobserved_empty_generation(index, observed): + index.mark_uncertain() + if observed == "rows": + index.write_paths(["retained"]) + elif observed == "queue": + index.seed_scan(1) + elif observed == "issue": + index.record_access_denied(1) + generation, selected = index.prepare_scan() + assert selected == (observed == "empty") + response = index.status() + assert response.last_verified_at_ms is None + if selected: + assert generation == 1 + assert response.coverage == "partial" + assert response.issue_count == 0 + else: + assert generation != 1 + assert response.coverage == "uncertain" + assert response.issue_count >= 1 + + +def test_incomplete_generation_is_not_reported_as_verified(sqlite_cache, monkeypatch): + monkeypatch.setattr("smbclient.scandir", lambda *args, **kwargs: (entry for entry in ())) + finish = sqlite_cache.index.finish_generation + + def incomplete(generation, **kwargs): + sqlite_cache.index.record_access_denied(generation) + return finish(generation, **kwargs) + + monkeypatch.setattr(sqlite_cache.index, "finish_generation", incomplete) + sqlite_cache._watcher_ready.set() + sqlite_cache._scan_disk_queue() + assert "coverage is incomplete" in sqlite_cache.scan_error + assert sqlite_cache.index.status().last_verified_at_ms is None + assert sqlite_cache.last_scan_time is None + assert sqlite_cache.coordinator.verification_required + + +def test_sqlite_source_policy_applies_at_connection_boundaries(sqlite_cache): + from app.models.system_settings import DirectoryIndexPolicy + + sqlite_cache.index.write_paths(["accepted"]) + before = sqlite_cache.search_index("accepted") + policy = DirectoryIndexPolicy(page_cache_kib=1024, healthy_verification_seconds=600, degraded_verification_seconds=120) + sqlite_cache.apply_policy(policy) + assert sqlite_cache.policy == policy + assert sqlite_cache.resources.policy == policy + with closing(sqlite_cache.index._connect(readonly=True)) as connection: + assert connection.execute("PRAGMA cache_size").fetchone() == (-1024,) + assert sqlite_cache.search_index("accepted") == before + + +def test_failed_verification_backoff_is_due_before_the_full_pass_interval(sqlite_cache): + from app.services.directory_cache import FAILURE_RETRY_JITTER_PERCENT + + sqlite_cache._retry_jitter = FAILURE_RETRY_JITTER_PERCENT + now = 1000.0 + verified_at = now + for seconds in (30, 60, 120, 240, 300, 300): + sqlite_cache._schedule_retry(now=now) + assert sqlite_cache._resource_retry_at == now + seconds + assert not sqlite_cache._verification_due(verified_at, now=now + seconds - 0.001) + assert sqlite_cache._verification_due(verified_at, now=now + seconds) + now += seconds + verified_at = now + sqlite_cache._reset_retry() + assert not sqlite_cache._verification_due(verified_at, now=now) + sqlite_cache._schedule_retry(now=now) + assert sqlite_cache._failure_retry_delay == 30 + + +@pytest.mark.parametrize("degraded", [False, True]) +def test_periodic_verification_jitter_preserves_deadlines_and_retry_priority(sqlite_cache, degraded): + from app.services.directory_cache import PERIODIC_VERIFICATION_JITTER_PERCENT + + sqlite_cache.coordinator.verification_required = degraded + sqlite_cache._watcher_ready.set() + interval = sqlite_cache.policy.degraded_verification_seconds if degraded else sqlite_cache.policy.healthy_verification_seconds + verified_at = 1000.0 + deadlines = set() + for jitter in range(256): + sqlite_cache._retry_jitter = jitter + delay = interval * (100 - jitter % (PERIODIC_VERIFICATION_JITTER_PERCENT + 1)) / 100 + deadline = verified_at + delay + deadlines.add(deadline) + assert not sqlite_cache._verification_due(verified_at, now=deadline - 0.001) + assert sqlite_cache._verification_due(verified_at, now=deadline) + assert verified_at + interval * 0.9 <= deadline <= verified_at + interval + sqlite_cache._resource_retry_at = deadline + 30 + assert not sqlite_cache._verification_due(verified_at, now=deadline) + assert sqlite_cache._verification_due(verified_at, now=deadline + 30) + sqlite_cache._resource_retry_at = 0 + assert len(deadlines) > 1 + sqlite_cache._retry_jitter = 0 + sqlite_cache._schedule_retry(now=verified_at) + assert sqlite_cache._verification_due(verified_at, now=sqlite_cache._resource_retry_at) + + +def test_retry_jitter_stays_bounded_and_spreads_backend_sources(sqlite_cache): + from app.services.directory_cache import FAILURE_RETRY_MAX_SECONDS, FAILURE_RETRY_MIN_SECONDS + + delays = set() + for jitter in range(256): + sqlite_cache._retry_jitter = jitter + sqlite_cache._reset_retry() + sqlite_cache._schedule_retry(now=1000.0) + delay = sqlite_cache._resource_retry_at - 1000.0 + assert FAILURE_RETRY_MIN_SECONDS <= delay <= FAILURE_RETRY_MAX_SECONDS + delays.add(delay) + sqlite_cache._failure_retry_delay = FAILURE_RETRY_MAX_SECONDS + sqlite_cache._schedule_retry(now=1000.0) + assert FAILURE_RETRY_MIN_SECONDS <= sqlite_cache._resource_retry_at - 1000.0 <= FAILURE_RETRY_MAX_SECONDS + assert len(delays) > 1 + + +def test_unchanged_policy_preserves_backoff_and_changed_policy_retries(sqlite_cache): + from app.models.system_settings import DirectoryIndexPolicy + + sqlite_cache._schedule_retry(now=1000.0) + deadline = sqlite_cache._resource_retry_at + sqlite_cache.apply_policy(sqlite_cache.policy) + assert sqlite_cache._resource_retry_at == deadline + assert sqlite_cache._failure_retry_delay > 0 + sqlite_cache.apply_policy(DirectoryIndexPolicy(page_cache_kib=1024)) + assert sqlite_cache._failure_retry_delay == 0 + assert sqlite_cache._resource_retry_at == 0 + + +@pytest.mark.asyncio +async def test_failed_root_retries_back_off_and_success_resets_delay(sqlite_cache, monkeypatch): + from contextlib import asynccontextmanager + from time import time + + unavailable = True + now = [1000.0] + attempts = [] + monkeypatch.setattr("app.services.directory_cache.time", SimpleNamespace(monotonic=lambda: now[0], time=time)) + + @asynccontextmanager + async def connection(**kwargs): + attempts.append(now[0]) + if unavailable: + raise OSError("Injected missing root") + yield + + async def pool(): + return SimpleNamespace(get_connection=connection, invalidate_connection_cache=AsyncMock()) + + monkeypatch.setattr("app.services.directory_cache.get_connection_pool", pool) + await sqlite_cache._run_rescan() + assert sqlite_cache._failure_retry_delay == 30 + deadline = sqlite_cache._resource_retry_at + for _ in range(3): + await sqlite_cache._run_rescan() + assert sqlite_cache._resource_retry_at == deadline + assert sqlite_cache._failure_retry_delay == 30 + assert attempts == [1000.0] + now[0] = deadline + await sqlite_cache._run_rescan() + assert sqlite_cache._failure_retry_delay == 60 + assert len(attempts) == 2 + unavailable = False + now[0] = sqlite_cache._resource_retry_at + monkeypatch.setattr(sqlite_cache, "_probe_source_root", lambda: None) + monkeypatch.setattr(sqlite_cache, "_scan_disk_queue", lambda: None) + await sqlite_cache._run_rescan() + assert sqlite_cache._failure_retry_delay == 0 + assert sqlite_cache._resource_retry_at == 0 + + +def test_root_unavailable_api_returns_specific_service_error(sqlite_cache, monkeypatch, client, auth_headers_user, test_connection): + sqlite_cache.index.write_paths(["accepted"]) + sqlite_cache._source_available.clear() + + async def get_source(**kwargs): + return sqlite_cache + + monkeypatch.setattr("app.services.directory_cache.get_directory_cache_manager", lambda: SimpleNamespace(get_or_create_cache=get_source)) + response = client.get(f"/api/browse/{test_connection.id}/directories", headers=auth_headers_user, params={"q": "accepted"}) + assert response.status_code == 503 + assert "root is unavailable" in response.json()["detail"]["message"] + assert response.json()["detail"]["recoverable"] is True + assert "Restore source access" in response.json()["detail"]["message"] + + +def test_sqlite_source_warm_search_does_not_traverse(sqlite_cache, monkeypatch): + def unexpected_scan(*args, **kwargs): + raise AssertionError("A warm directory query must not scan the source.") + + monkeypatch.setattr("smbclient.scandir", unexpected_scan) + sqlite_cache.index.write_paths(["alpha/beta", "beta", "neither"]) + assert sqlite_cache.search("alpha beta") == (["alpha/beta", "beta"], 2) + assert not hasattr(sqlite_cache, "_directories") + + +def test_sqlite_api_uses_one_read_view_off_the_event_loop(sqlite_cache, monkeypatch, client, auth_headers_user, test_connection): + event_loop_threads = [] + query_threads = [] + original_search = sqlite_cache.search_index + + async def get_source(**kwargs): + event_loop_threads.append(threading.get_ident()) + return sqlite_cache + + def query(*args, **kwargs): + query_threads.append(threading.get_ident()) + return original_search(*args, **kwargs) + + sqlite_cache.index.write_paths(["alpha/beta", "beta", "neither"]) + monkeypatch.setattr(sqlite_cache, "search_index", query) + monkeypatch.setattr("app.services.directory_cache.get_directory_cache_manager", lambda: SimpleNamespace(get_or_create_cache=get_source)) + response = client.get(f"/api/browse/{test_connection.id}/directories", headers=auth_headers_user, params={"q": "alpha beta"}) + assert response.status_code == 200 + body = response.json() + assert body["results"] == ["alpha/beta", "beta"] + assert body["matched_term_counts"] == {"alpha/beta": 2, "beta": 1} + assert body["match_ranks"] == {"alpha/beta": 0, "beta": 0} + assert body["total_matches"] == 2 + assert body["directory_count"] == 3 + assert body["accepted_revision"] == sqlite_cache.index.status().accepted_revision + assert body["coverage"] == "partial" + assert event_loop_threads[0] != query_threads[0] + + +def test_sqlite_api_empty_query_returns_coverage_without_matches(sqlite_cache, monkeypatch, client, auth_headers_user, test_connection): + async def get_source(**kwargs): + return sqlite_cache + + sqlite_cache.index.write_paths(["alpha"]) + monkeypatch.setattr("app.services.directory_cache.get_directory_cache_manager", lambda: SimpleNamespace(get_or_create_cache=get_source)) + response = client.get(f"/api/browse/{test_connection.id}/directories", headers=auth_headers_user, params={"q": ""}) + assert response.status_code == 200 + assert response.json()["results"] == [] + assert response.json()["directory_count"] == 1 + assert response.json()["coverage"] == "partial" + + +def test_directory_api_rejects_excess_terms_before_source_creation(monkeypatch, client, auth_headers_user, test_connection): + def unexpected_manager(): + raise AssertionError("Invalid queries must not initialize a directory source.") + + monkeypatch.setattr("app.services.directory_cache.get_directory_cache_manager", unexpected_manager) + response = client.get( + f"/api/browse/{test_connection.id}/directories", + headers=auth_headers_user, + params={"q": " ".join(f"term{number}" for number in range(MAX_QUERY_TERMS + 1))}, + ) + assert response.status_code == 400 + assert "distinct terms" in response.json()["detail"] + + +@pytest.mark.parametrize("boundary", ["next", "close"]) +@pytest.mark.parametrize("stop", [False, True]) +@pytest.mark.parametrize("has_child", [False, True]) +def test_root_scan_rejects_late_availability_and_parent_completion(sqlite_cache, monkeypatch, boundary, stop, has_child): + from smbclient._os import FileAttributes + + from app.services.directory_cache import ScanObservationInvalidated + + sqlite_cache.index.write_paths(["accepted"]) + assert sqlite_cache.index.finish_generation(1) + before = sqlite_cache.index.search("accepted") + sqlite_cache._source_available.clear() + sqlite_cache._source_probe_completed.clear() + completed = [] + closed = [] + + def interrupt(): + if stop: + sqlite_cache._stop_event.set() + else: + sqlite_cache.coordinator.invalidate() + + class Entries: + def __init__(self): + self.returned_child = False + + def __next__(self): + if boundary == "next": + interrupt() + if has_child and not self.returned_child: + self.returned_child = True + return SimpleNamespace(name="candidate", smb_info=SimpleNamespace(file_attributes=FileAttributes.FILE_ATTRIBUTE_DIRECTORY)) + raise StopIteration + + def close(self): + closed.append(True) + if boundary == "close": + interrupt() + + monkeypatch.setattr("smbclient.scandir", lambda *args, **kwargs: Entries()) + monkeypatch.setattr(sqlite_cache.index, "finish_scan_parent", lambda *args: completed.append(args)) + with pytest.raises((InterruptedError, ScanObservationInvalidated)): + sqlite_cache._scan_disk_queue() + assert closed == [True] + assert not completed + assert not sqlite_cache._source_available.is_set() + assert not sqlite_cache._source_probe_completed.is_set() + assert sqlite_cache.index.search("accepted") == before + assert sqlite_cache.coordinator.verification_required + assert not sqlite_cache.coordinator._producers + assert not sqlite_cache._scan_running.is_set() + assert sqlite_cache._manager_scan_slots.acquire(blocking=False) + sqlite_cache._manager_scan_slots.release() + + +def test_sqlite_source_initial_batches_are_visible_before_parent_completion(sqlite_cache, monkeypatch): + from smbclient._os import FileAttributes + + observed = [] + + def scan(path, **kwargs): + if path == sqlite_cache._smb_base_path: + for number in range(257): + if number == 256: + observed.append(sqlite_cache.search_index("directory").total_matches) + yield SimpleNamespace( + name=f"directory{number}", smb_info=SimpleNamespace(file_attributes=FileAttributes.FILE_ATTRIBUTE_DIRECTORY) + ) + + monkeypatch.setattr("smbclient.scandir", scan) + sqlite_cache._scan_disk_queue() + assert observed == [256] + response = sqlite_cache.search_index("directory") + assert response.directory_count == response.total_matches == 257 + assert response.coverage == "complete" + assert not hasattr(sqlite_cache, "_directories") + + +def test_sqlite_source_excludes_reparse_directories_and_reports_partial_coverage(sqlite_cache, monkeypatch): + from smbclient._os import FileAttributes + + def scan(path, **kwargs): + if path == sqlite_cache._smb_base_path: + for name in ("redirected", "unknown-placeholder"): + yield SimpleNamespace( + name=name, + smb_info=SimpleNamespace( + file_attributes=FileAttributes.FILE_ATTRIBUTE_DIRECTORY | FileAttributes.FILE_ATTRIBUTE_REPARSE_POINT + ), + ) + yield SimpleNamespace(name="regular", smb_info=SimpleNamespace(file_attributes=FileAttributes.FILE_ATTRIBUTE_DIRECTORY)) + elif path != sqlite_cache._smb_base_path + "\\regular": + raise AssertionError("Excluded reparse directories must not be read.") + + monkeypatch.setattr("smbclient.scandir", scan) + sqlite_cache._scan_disk_queue() + response = sqlite_cache.search_index("regular redirected placeholder") + assert response.total_matches == response.directory_count == 1 + assert response.coverage == "partial" + assert response.issue_count == 2 + assert response.last_verified_at_ms is None + assert sqlite_cache.coordinator.verification_required + + def recovered_scan(path, **kwargs): + if path == sqlite_cache._smb_base_path: + yield SimpleNamespace(name="regular", smb_info=SimpleNamespace(file_attributes=FileAttributes.FILE_ATTRIBUTE_DIRECTORY)) + + monkeypatch.setattr("smbclient.scandir", recovered_scan) + sqlite_cache._scan_disk_queue() + recovered = sqlite_cache.search_index("regular redirected placeholder") + assert recovered.total_matches == recovered.directory_count == 1 + assert recovered.issue_count == 0 + assert recovered.coverage == "complete" + assert recovered.last_verified_at_ms is not None + assert not sqlite_cache.coordinator.verification_required + + +def test_sqlite_source_reparse_candidate_preserves_accepted_rows(sqlite_cache, monkeypatch): + from smbclient._os import FileAttributes + + sqlite_cache.index.write_paths(["accepted"]) + assert sqlite_cache.index.finish_generation(1) + before = sqlite_cache.index.search("accepted") + + def scan(path, **kwargs): + yield SimpleNamespace( + name="redirected", + smb_info=SimpleNamespace(file_attributes=FileAttributes.FILE_ATTRIBUTE_DIRECTORY | FileAttributes.FILE_ATTRIBUTE_REPARSE_POINT), + ) + + monkeypatch.setattr("smbclient.scandir", scan) + sqlite_cache._scan_disk_queue() + assert sqlite_cache.index.search("accepted") == before + assert sqlite_cache.coordinator.verification_required + + +@pytest.mark.parametrize("denied_at_open", [False, True]) +@pytest.mark.parametrize("smb_denial", [False, True]) +def test_sqlite_source_denied_descendant_preserves_readable_siblings(sqlite_cache, monkeypatch, denied_at_open, smb_denial): + from smbclient._os import FileAttributes + from smbprotocol.exceptions import SMBOSError + from smbprotocol.header import NtStatus + + reads = [] + + def deny(): + if smb_denial: + raise SMBOSError(NtStatus.STATUS_ACCESS_DENIED, "fixture") + raise PermissionError("Injected source denial.") + + def entries(path): + if path == sqlite_cache._smb_base_path: + for name in ("denied", "readable"): + yield SimpleNamespace(name=name, smb_info=SimpleNamespace(file_attributes=FileAttributes.FILE_ATTRIBUTE_DIRECTORY)) + elif path.endswith("\\denied"): + deny() + elif path.endswith("\\readable"): + yield SimpleNamespace(name="child", smb_info=SimpleNamespace(file_attributes=FileAttributes.FILE_ATTRIBUTE_DIRECTORY)) + + def scan(path, **kwargs): + reads.append(path) + if denied_at_open and path.endswith("\\denied"): + deny() + return entries(path) + + monkeypatch.setattr("smbclient.scandir", scan) + sqlite_cache._scan_disk_queue() + response = sqlite_cache.search_index("denied readable") + assert response.directory_count == response.total_matches == 3 + assert response.coverage == "partial" + assert response.issue_count == 1 + assert response.last_verified_at_ms is None + assert sqlite_cache.coordinator.verification_required + assert sqlite_cache.index.pending_scan_paths(1) == [] + assert sqlite_cache._smb_base_path + "\\readable\\child" in reads + assert sqlite_cache.index._connection().execute("SELECT code, count FROM coverage_issues").fetchall() == [("access_denied", 1)] + + +def test_sqlite_source_denied_candidate_preserves_accepted_rows_and_recovers(sqlite_cache, monkeypatch): + from smbclient._os import FileAttributes + + sqlite_cache.index.write_paths(["accepted", "denied/retained"]) + assert sqlite_cache.index.finish_generation(1) + before = sqlite_cache.index.search("accepted denied") + denied = True + + def scan(path, **kwargs): + if path == sqlite_cache._smb_base_path: + for name in ("denied", "readable"): + yield SimpleNamespace(name=name, smb_info=SimpleNamespace(file_attributes=FileAttributes.FILE_ATTRIBUTE_DIRECTORY)) + elif path.endswith("\\denied") and denied: + raise PermissionError("Injected source denial.") + + monkeypatch.setattr("smbclient.scandir", scan) + sqlite_cache._scan_disk_queue() + assert sqlite_cache.index.search("accepted denied") == before + assert sqlite_cache.coordinator.verification_required + denied = False + sqlite_cache._scan_disk_queue() + response = sqlite_cache.search_index("accepted denied readable") + assert response.directory_count == response.total_matches == 2 + assert response.coverage == "complete" + assert response.issue_count == 0 + assert response.last_verified_at_ms is not None + assert not sqlite_cache.coordinator.verification_required + + +@pytest.mark.parametrize("parent", ["", "parent"]) +def test_stale_source_call_preserves_source_availability(sqlite_cache, parent): + from app.services.directory_cache import ScanObservationInvalidated + + original = ScanObservationInvalidated("Injected stale source observation.") + + def stale(): + raise original + + with pytest.raises(ScanObservationInvalidated) as caught: + sqlite_cache._source_call(parent, stale) + assert caught.value is original + assert sqlite_cache._source_available.is_set() + assert sqlite_cache._source_probe_completed.is_set() + assert not sqlite_cache._root_reading.is_set() + + +def test_sqlite_source_root_permission_denial_blocks_results(sqlite_cache, monkeypatch): + from app.services.directory_cache import DirectorySourceUnavailableError + + sqlite_cache.index.write_paths(["accepted"]) + + def denied(*args, **kwargs): + raise PermissionError("Injected root denial.") + + monkeypatch.setattr("smbclient.scandir", denied) + with pytest.raises(DirectorySourceUnavailableError): + sqlite_cache._scan_disk_queue() + with pytest.raises(DirectorySourceUnavailableError): + sqlite_cache.search_index("accepted") + assert sqlite_cache.index.search("accepted").total_matches == 1 + + +def test_sqlite_source_storage_permission_denial_is_not_a_source_issue(sqlite_cache, monkeypatch): + from smbclient._os import FileAttributes + + def scan(path, **kwargs): + yield SimpleNamespace(name="child", smb_info=SimpleNamespace(file_attributes=FileAttributes.FILE_ATTRIBUTE_DIRECTORY)) + + def denied(*args, **kwargs): + raise PermissionError("Injected local storage denial.") + + monkeypatch.setattr("smbclient.scandir", scan) + monkeypatch.setattr(sqlite_cache.index, "write_scan_children", denied) + with pytest.raises(PermissionError, match="local storage denial"): + sqlite_cache._scan_parent(1, "parent") + assert sqlite_cache.index._connection().execute("SELECT count(*) FROM coverage_issues").fetchone()[0] == 0 + assert sqlite_cache._source_available.is_set() + + +@pytest.mark.parametrize("failure", ["storage", "stale", "cancelled", "task_cancelled"]) +@pytest.mark.parametrize("parent", ["", "parent"]) +def test_sqlite_source_close_denial_preserves_primary_failure(sqlite_cache, monkeypatch, failure, parent): + from smbclient._os import FileAttributes + + from app.services.directory_cache import ScanObservationInvalidated + + errors = { + "storage": PermissionError("Injected local storage denial."), + "stale": ScanObservationInvalidated("Injected stale read."), + "cancelled": InterruptedError("Injected cancelled read."), + "task_cancelled": asyncio.CancelledError("Injected task cancellation."), + } + original = errors[failure] + closed = [] + warnings = [] + + def warning(message, *args): + warnings.append(message % args) + + monkeypatch.setattr("app.services.directory_cache.logger.warning", warning) + + class Entries: + def __init__(self): + self.items = iter( + [SimpleNamespace(name="child", smb_info=SimpleNamespace(file_attributes=FileAttributes.FILE_ATTRIBUTE_DIRECTORY))] + ) + + def __next__(self): + return next(self.items) + + def close(self): + closed.append(True) + raise PermissionError("Injected close denial with private source path.") + + def reject_batch(*args, **kwargs): + raise original + + sqlite_cache.index.seed_scan(1) + if parent: + sqlite_cache.index.write_scan_children(1, [(parent, True)]) + sqlite_cache.index.finish_scan_parent(1, "") + monkeypatch.setattr("smbclient.scandir", lambda *args, **kwargs: Entries()) + monkeypatch.setattr(sqlite_cache.coordinator, "commit_children", reject_batch) + with pytest.raises(type(original)) as caught: + sqlite_cache._scan_parent(1, parent) + assert caught.value is original + assert closed == [True] + assert sqlite_cache.index.pending_scan_paths(1) == [parent] + assert sqlite_cache.index._connection().execute("SELECT count(*) FROM coverage_issues").fetchone()[0] == 0 + assert len(warnings) == 1 + assert "Directory source read cleanup failed" in warnings[0] + assert "private source path" not in warnings[0] + + +def test_sqlite_source_close_failure_keeps_parent_pending(sqlite_cache, monkeypatch): + sqlite_cache.index.seed_scan(1) + sqlite_cache.index.write_scan_children(1, [("parent", True)]) + sqlite_cache.index.finish_scan_parent(1, "") + closed = [] + + class Entries: + def __next__(self): + raise StopIteration + + def close(self): + closed.append(True) + raise OSError("Injected close failure.") + + monkeypatch.setattr("smbclient.scandir", lambda *args, **kwargs: Entries()) + with pytest.raises(OSError, match="close failure"): + sqlite_cache._scan_parent(1, "parent") + assert closed == [True] + assert sqlite_cache.index.pending_scan_paths(1) == ["parent"] + + +def test_sqlite_source_late_permission_denial_cannot_finish_stale_parent(sqlite_cache, monkeypatch): + from app.services.directory_cache import ScanObservationInvalidated + + sqlite_cache.index.seed_scan(1) + sqlite_cache.index.write_scan_children(1, [("parent", True)]) + sqlite_cache.index.finish_scan_parent(1, "") + + def denied(*args, **kwargs): + sqlite_cache.coordinator.invalidate() + raise PermissionError("Injected late denial.") + + monkeypatch.setattr("smbclient.scandir", denied) + with pytest.raises(ScanObservationInvalidated): + sqlite_cache._scan_parent(1, "parent") + assert sqlite_cache.index.pending_scan_paths(1) == ["parent"] + assert sqlite_cache.index._connection().execute("SELECT count(*) FROM coverage_issues").fetchone()[0] == 0 + + +def test_sqlite_source_rejects_late_parent_observations(sqlite_cache, monkeypatch): + from smbclient._os import FileAttributes + + from app.services.directory_cache import ScanObservationInvalidated + + def scan(path, **kwargs): + yield SimpleNamespace(name="alpha", smb_info=SimpleNamespace(file_attributes=FileAttributes.FILE_ATTRIBUTE_DIRECTORY)) + sqlite_cache.remove_directory("alpha") + + monkeypatch.setattr("smbclient.scandir", scan) + with pytest.raises(ScanObservationInvalidated): + sqlite_cache._scan_disk_queue() + assert sqlite_cache.search_index("alpha").total_matches == 0 + assert sqlite_cache.coordinator.verification_required + + +def test_sqlite_source_failed_initial_build_is_verified_in_fresh_candidate(sqlite_cache, monkeypatch): + sqlite_cache.index.seed_scan(1) + sqlite_cache.index.write_scan_children(1, [("stale", False)]) + sqlite_cache.index.finish_scan_parent(1, "") + + def empty_scan(path, **kwargs): + assert sqlite_cache.search_index("stale").total_matches == 1 + yield from () + + monkeypatch.setattr("smbclient.scandir", empty_scan) + sqlite_cache._scan_disk_queue() + assert sqlite_cache.search_index("stale").total_matches == 0 + assert sqlite_cache.search_index("").coverage == "complete" + + +@pytest.mark.asyncio +async def test_sqlite_source_cancellation_retains_smb_pool_future(sqlite_cache): + from unittest.mock import AsyncMock + + started = threading.Event() + release = threading.Event() + pool = SimpleNamespace(retain_connection_until_future_complete=AsyncMock()) + + def blocked(): + started.set() + assert release.wait(timeout=5) + + task = asyncio.create_task(sqlite_cache._blocking(blocked, pool=pool)) + try: + assert await asyncio.to_thread(started.wait, 2) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + pool.retain_connection_until_future_complete.assert_awaited_once() + connection_cache, future = pool.retain_connection_until_future_complete.call_args.args + assert connection_cache is sqlite_cache._connection_cache + assert not future.done() + release.set() + await asyncio.wait_for(asyncio.shield(future), timeout=5) + finally: + release.set() + await asyncio.gather(task, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_sqlite_source_stalled_read_retains_worker_and_pool_ownership(sqlite_cache, monkeypatch): + from unittest.mock import AsyncMock + + release = threading.Event() + started = threading.Event() + pool = SimpleNamespace(retain_connection_until_future_complete=AsyncMock(), invalidate_connection_cache=AsyncMock()) + + def blocked(): + started.set() + assert release.wait(timeout=5) + + monkeypatch.setattr("app.services.directory_cache.SCAN_TIMEOUT_SECONDS", 0.02) + monkeypatch.setattr("app.services.directory_cache.WORKER_POLL_SECONDS", 0.005) + try: + with pytest.raises(TimeoutError, match="capacity stays reserved"): + await sqlite_cache._blocking(blocked, pool=pool) + assert started.is_set() + assert sqlite_cache._active_workers == 1 + pool.retain_connection_until_future_complete.assert_awaited_once() + pool.invalidate_connection_cache.assert_not_awaited() + future = pool.retain_connection_until_future_complete.call_args.args[1] + assert not future.done() + assert sqlite_cache.coordinator.verification_required + release.set() + await asyncio.wait_for(asyncio.shield(future), timeout=5) + assert sqlite_cache._active_workers == 0 + finally: + release.set() + + +@pytest.mark.asyncio +async def test_sqlite_source_cancelled_worker_keeps_process_lease(sqlite_cache): + started = threading.Event() + release = threading.Event() + + def blocked(): + started.set() + assert release.wait(timeout=5) + + task = asyncio.create_task(sqlite_cache._blocking(blocked)) + closing = None + try: + assert await asyncio.to_thread(started.wait, 2) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + sqlite_cache._request_stop() + closing = asyncio.create_task(asyncio.to_thread(sqlite_cache._finish_stop)) + contender = DirectoryIndex(sqlite_cache.index.path, source_kind=sqlite_cache.index.source_kind, source_identity={}) + with pytest.raises(BlockingIOError): + contender.open() + assert not closing.done() + finally: + release.set() + if closing is not None: + await asyncio.wait_for(closing, timeout=5) + else: + await asyncio.gather(task, return_exceptions=True) + + +APPLICATION_ID = 1396853073 +SCHEMA_VERSION = 1 + + +def test_generated_unicode_contract_matches_backend() -> None: + contract = json.loads((CONTRACT_ROOT / "unicode.json").read_text(encoding="utf-8")) + assert contract["unicode_version"] == unicodedata.unidata_version + for character, normalized in contract["normalization"]: + assert normalize_search_text(character) == normalized + assert contract["whitespace"] == [chr(codepoint) for codepoint in range(0x110000) if chr(codepoint).isspace()] + + +def test_query_admission_is_bounded(index): + from app.services.directory_index import QUERY_WORKERS + + for _ in range(QUERY_WORKERS): + assert index._query_slots.acquire(blocking=False) + try: + with pytest.raises(TimeoutError, match="workers are busy"): + index.search("alpha") + finally: + for _ in range(QUERY_WORKERS): + index._query_slots.release() + assert index.search("alpha").total_matches == 0 + + +@pytest.mark.parametrize("suffix", ["", "-wal", "-shm", "-journal", ".lease"]) +def test_rejects_linked_storage_files_before_sqlite_io(tmp_path, monkeypatch, suffix): + directory = tmp_path / "private" + directory.mkdir(mode=0o700) + path = directory / "source.sqlite3" + target = tmp_path / "outside" + target.write_bytes(b"unchanged") + Path(str(path) + suffix).symlink_to(target) + store = DirectoryIndex(path, source_kind="local", source_identity={"root": "fixture"}) + monkeypatch.setattr(store, "_connect", lambda **_kwargs: pytest.fail("SQLite I/O must not begin")) + with pytest.raises(PermissionError, match="regular files"): + store.open() + assert target.read_bytes() == b"unchanged" + assert store._lease_fd is None + + +def test_new_database_and_lease_are_private(index): + import os + import stat + + if os.name == "posix": + assert stat.S_IMODE(index.path.stat().st_mode) == 0o600 + assert stat.S_IMODE(Path(str(index.path) + ".lease").stat().st_mode) == 0o600 + + +def test_accepted_parent_reconciliation_queues_only_new_subtrees(index): + index.write_paths(["parent", "parent/existing"]) + assert index.finish_generation(1) + before = index.search("parent") + with pytest.raises(ValueError): + index.write_scan_children(1, [("parent/unowned", False)]) + with pytest.raises(ValueError): + index.write_reconciliation_children(1, "parent", [("parent/unowned", False)]) + index.queue_reconciliation_parent(1, "parent") + assert index.search("parent") == before + index.close() + index.open() + assert index.pending_scan_paths(1) == ["parent"] + index.write_reconciliation_children(1, "parent", [("parent/existing", True), ("parent/new", True)]) + after = index.search("parent") + assert after.directory_count == 3 + assert after.accepted_revision == before.accepted_revision + 1 + assert after.last_verified_at_ms == before.last_verified_at_ms + assert index.pending_scan_paths(1) == ["parent", "parent/new"] + index.write_reconciliation_children(1, "parent", [("parent/existing", True), ("parent/new", True)]) + assert index.search("parent") == after + index.write_reconciliation_children(1, "parent/new", [("parent/new/child", False)]) + index.finish_reconciliation_parent(1, "parent/new") + index.finish_reconciliation_parent(1, "parent") + index.queue_reconciliation_parent(1, "parent") + before_repeat = index.search("parent") + index.write_reconciliation_children(1, "parent", [("parent/new", True)]) + assert index.pending_scan_paths(1) == ["parent"] + assert index.search("parent") == before_repeat + assert before_repeat.directory_count == 4 + assert before_repeat.last_verified_at_ms == before.last_verified_at_ms + + +def test_accepted_parent_reconciliation_rejects_stale_and_unrelated_work(index): + from app.services.directory_index import MAX_SIGNED_INTEGER, ROW_BATCH_SIZE + + assert index.accepted_generation() is None + with pytest.raises(ValueError): + index.queue_reconciliation_parent(1, "") + index.write_paths(["parent"]) + assert index.finish_generation(1) + assert index.accepted_generation() == 1 + index.queue_reconciliation_parent(1, "parent") + assert index.reconciliation_generation() == 1 + before = index.search("parent other") + for invalid in ("other/child", "parent", "parent/deep/child"): + with pytest.raises(ValueError, match="immediate parent"): + index.write_reconciliation_children(1, "parent", [("parent/safe", False), (invalid, False)]) + assert index.search("parent other") == before + with pytest.raises(ValueError, match="row batch"): + index.write_reconciliation_children(1, "parent", ((f"parent/{number}", False) for number in range(ROW_BATCH_SIZE + 1))) + assert index.search("parent other") == before + index.finish_reconciliation_parent(1, "parent") + assert index.reconciliation_generation() is None + with pytest.raises(ValueError): + index.finish_reconciliation_parent(1, "parent") + with pytest.raises(ValueError): + index.write_reconciliation_children(1, "parent", [("parent/late", False)]) + index.queue_reconciliation_parent(1, "parent") + candidate = index.begin_verification() + for operation in ( + lambda: index.queue_reconciliation_parent(candidate, "parent"), + lambda: index.write_reconciliation_children(candidate, "parent", [("parent/late", False)]), + lambda: index.finish_reconciliation_parent(candidate, "parent"), + ): + with pytest.raises(ValueError): + operation() + index.write_paths(["replacement"], generation_id=candidate) + assert index.finish_generation(candidate) + selected = index.search("replacement parent") + for generation in (1, MAX_SIGNED_INTEGER): + for operation in ( + lambda: index.queue_reconciliation_parent(generation, "parent"), + lambda: index.write_reconciliation_children(generation, "parent", [("parent/late", False)]), + lambda: index.finish_reconciliation_parent(generation, "parent"), + ): + with pytest.raises(ValueError): + operation() + assert index.search("replacement parent") == selected + + +def test_accepted_parent_reconciliation_bounds_bytes_and_root_children(index, monkeypatch): + index.write_paths(["existing"]) + assert index.finish_generation(1) + index.queue_reconciliation_parent(1, "") + before = index.search("existing root") + consumed = [] + + def children(): + for path in ("safe", "\u00e9" * 3): + consumed.append(path) + yield path, True + raise AssertionError("An oversized batch must not consume further children.") + + with monkeypatch.context() as budget: + budget.setattr("app.services.directory_index.SCAN_BATCH_BYTES", 9) + with pytest.raises(ValueError, match="byte limit"): + index.write_reconciliation_children(1, "", children()) + assert len(consumed) == 2 + assert index.search("existing root") == before + assert index.pending_scan_paths(1) == [""] + index.write_reconciliation_children(1, "", [(prefix + "\u00e9", False) for prefix in ("a", "b", "c")]) + assert index.status().directory_count == 4 + before_invalid = index.status() + with pytest.raises(ValueError, match="immediate parent"): + index.write_reconciliation_children(1, "", [("root-child", True), ("nested/child", False)]) + assert index.status() == before_invalid + index.write_reconciliation_children(1, "", [("root-child", True)]) + index.finish_reconciliation_parent(1, "") + assert index.pending_scan_paths(1) == ["root-child"] + after = index.search("existing root") + assert after.directory_count == 5 + assert after.last_verified_at_ms == before.last_verified_at_ms + + +@pytest.mark.parametrize("failure", ["queue", "revision", "completion"]) +def test_failed_accepted_parent_reconciliation_rolls_back_rows_and_work(index, failure): + index.write_paths(["parent"]) + assert index.finish_generation(1) + index.queue_reconciliation_parent(1, "parent") + before = index.search("parent") + triggers = { + "queue": "BEFORE INSERT ON scan_queue WHEN NEW.path = 'parent/abort'", + "revision": "BEFORE UPDATE ON source_metadata", + "completion": "BEFORE UPDATE ON scan_queue WHEN OLD.path = 'parent'", + } + index._connection().execute(f"CREATE TRIGGER reject_reconciliation {triggers[failure]} BEGIN SELECT RAISE(ABORT, 'injected'); END") + + def operation(): + if failure == "completion": + index.finish_reconciliation_parent(1, "parent") + else: + index.write_reconciliation_children(1, "parent", [("parent/abort", True)]) + + with pytest.raises(sqlite3.IntegrityError): + operation() + assert index.search("parent") == before + assert index.pending_scan_paths(1) == ["parent"] + index._connection().execute("DROP TRIGGER reject_reconciliation") + operation() + if failure == "completion": + assert index.pending_scan_paths(1) == [] + assert index.search("parent") == before + else: + assert index.search("parent").directory_count == 2 + assert index.pending_scan_paths(1) == ["parent", "parent/abort"] + + +def test_accepted_parent_reconciliation_headroom_refusal_retains_work(index, monkeypatch): + from app.services.directory_index_resources import DirectoryDiskBudgetError + + index.write_paths(["parent"]) + assert index.finish_generation(1) + index.queue_reconciliation_parent(1, "parent") + before = index.search("parent") + + def denied(): + raise DirectoryDiskBudgetError("Injected reconciliation headroom refusal.") + + with monkeypatch.context() as refusal: + refusal.setattr(index, "growth_guard", denied) + for operation in ( + lambda: index.queue_reconciliation_parent(1, "other"), + lambda: index.write_reconciliation_children(1, "parent", [("parent/child", False)]), + lambda: index.finish_reconciliation_parent(1, "parent"), + ): + with pytest.raises(DirectoryDiskBudgetError): + operation() + assert index.pending_scan_paths(1) == ["parent"] + assert index.search("parent") == before + index.write_reconciliation_children(1, "parent", [("parent/child", False)]) + index.finish_reconciliation_parent(1, "parent") + assert index.reconciliation_generation() is None + after = index.search("parent") + assert after.directory_count == 2 + assert after.last_verified_at_ms == before.last_verified_at_ms + + +@pytest.mark.asyncio +@pytest.mark.parametrize("abandon", ["cancel", "timeout"]) +async def test_abandoned_smb_mutation_requeues_only_after_actual_worker_exit(sqlite_cache, monkeypatch, abandon): + from app.storage.smb import SMBBackend + from app.storage.smb_errors import SMBMutationUncertain + + backend = SMBBackend(host="fixture", share_name="share", username="user", password="password", connection_context_key="fixture") + manager = SimpleNamespace(get_cache=lambda _connection_id: sqlite_cache) + monkeypatch.setattr("app.services.directory_cache.get_directory_cache_manager", lambda: manager) + entered = threading.Event() + release = threading.Event() + retained = [] + + def mutate(): + entered.set() + assert release.wait(timeout=5) + + async def retain(cache, future): + assert cache is backend._connection_cache + retained.append(future) + + async def invalidate(**kwargs): + return None + + async def pool(): + return SimpleNamespace( + retain_connection_until_future_complete=retain, + invalidate_connection=invalidate, + connection_diagnostics=lambda _cache: {}, + ) + + async def timeout(future, **kwargs): + assert await asyncio.to_thread(entered.wait, 5) + raise TimeoutError("Injected timeout while native mutation is blocked.") + + monkeypatch.setattr("app.storage.smb.get_connection_pool", pool) + if abandon == "timeout": + monkeypatch.setattr(backend, "_wait_for_blocking_smb_operation", timeout) + task = asyncio.create_task( + backend._run_blocking_smb_operation( + "injected mutation", + mutate, + 5, + smb_path=backend._build_smb_path("parent/item"), + mutation_paths=(backend._build_smb_path("parent/item"),), + ) + ) + token = None + try: + assert await asyncio.to_thread(entered.wait, 5) + token = sqlite_cache.coordinator.begin_read("parent") + if abandon == "cancel": + task.cancel() + with pytest.raises(asyncio.CancelledError if abandon == "cancel" else SMBMutationUncertain): + await task + assert len(retained) == 1 + assert not retained[0].done() + assert len(sqlite_cache.coordinator.pending) == 1 + assert sqlite_cache.coordinator.is_current(token) + finally: + release.set() + if retained: + await asyncio.wait_for(asyncio.shield(retained[0]), 5) + elif not task.done(): + await asyncio.wait_for(task, 5) + if token is not None: + current = sqlite_cache.coordinator.is_current(token) + sqlite_cache.coordinator.end_read(token) + assert not current + assert len(sqlite_cache.coordinator.pending) == 2 + assert all(mutation.path == "parent" for _sequence, mutation, _size in sqlite_cache.coordinator.pending) + + +@pytest.mark.parametrize("binding", ["context", "root", "credentials", "stopped", "unowned"]) +def test_smb_mutation_hints_cannot_cross_source_bindings(sqlite_cache, monkeypatch, binding): + from app.storage.smb import SMBBackend + + key = None if binding == "unowned" else "other" if binding == "context" else "fixture" + backend = SMBBackend(host="fixture", share_name="share", username="user", password="password", connection_context_key=key) + manager = SimpleNamespace(get_cache=lambda _connection_id: sqlite_cache) + monkeypatch.setattr("app.services.directory_cache.get_directory_cache_manager", lambda: manager) + if binding == "root": + backend._path_prefix = "other" + elif binding == "credentials": + backend.password = "other" + elif binding == "stopped": + sqlite_cache._stop_event.set() + backend._observe_mutation_paths((backend._build_smb_path("parent/item"),)) + assert not sqlite_cache.coordinator.pending + sqlite_cache._stop_event.clear() + + +@pytest.mark.asyncio +async def test_smb_hint_failure_preserves_mutation_result_and_private_error_text(sqlite_cache, monkeypatch): + from app.storage import smb + + backend = smb.SMBBackend(host="fixture", share_name="share", username="user", password="password", connection_context_key="fixture") + manager = SimpleNamespace(get_cache=lambda _connection_id: sqlite_cache) + monkeypatch.setattr("app.services.directory_cache.get_directory_cache_manager", lambda: manager) + messages = [] + + def failed_hint(parent): + raise PermissionError("private source and account text") + + monkeypatch.setattr(sqlite_cache, "observe_parent", failed_hint) + monkeypatch.setattr(smb.logger, "warning", lambda *args: messages.append(args)) + result = await backend._run_blocking_smb_operation( + "injected mutation", + lambda: "source result", + 5, + smb_path=backend._build_smb_path("parent/item"), + mutation_paths=(backend._build_smb_path("parent/item"),), + ) + assert result == "source result" + assert sqlite_cache.coordinator.verification_required + assert sqlite_cache.coordinator.epoch == 2 + assert len(messages) == 2 + assert all(arguments == ("Directory mutation hints could not be queued (%s).", "PermissionError") for arguments in messages) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failed", [False, True]) +@pytest.mark.parametrize( + "operation,prefix", + [("create", ""), ("create", "scoped"), ("archive", "scoped"), ("mixed_archive", "scoped"), ("upload", "scoped")], +) +async def test_smb_mutation_queues_parent_before_dispatch_and_after_exit(sqlite_cache, monkeypatch, failed, prefix, operation): + from contextlib import asynccontextmanager, contextmanager + from io import BytesIO + from pathlib import PureWindowsPath + from unittest.mock import AsyncMock + + from smbclient._os import FileAttributes + + from app.api.archive_operations import _ensure_mixed_archive_parent_directories + from app.models.file import FileType + from app.services.archive.extraction import _ensure_live_directory + from app.storage.smb import SMBBackend + + index = sqlite_cache.index + index.write_paths(["parent"]) + assert index.finish_generation(1) + before = index.search("parent") + manager = SimpleNamespace(get_cache=lambda _connection_id: sqlite_cache) + monkeypatch.setattr("app.services.directory_cache.get_directory_cache_manager", lambda: manager) + sqlite_cache._path_prefix = prefix + backend = SMBBackend( + host="fixture", share_name="share", username="user", password="password", connection_context_key="fixture", path_prefix=prefix + ) + dispatched = [] + source_directories = {"parent"} + root = PureWindowsPath(backend._build_smb_path("")) + sqlite_cache._smb_base_path = str(root) + + def relative_path(path): + relative = PureWindowsPath(path).relative_to(root).as_posix() + return "" if relative == "." else relative + + @asynccontextmanager + async def connected(**kwargs): + yield + + async def pool(): + return SimpleNamespace(get_connection=connected, connection_diagnostics=lambda _cache: {}, generation_aborting=lambda _cache: False) + + def create(path, **kwargs): + dispatched.append(path) + relative = relative_path(path) + assert sqlite_cache.coordinator.pending[-1][1].path == relative.rpartition("/")[0] + if relative in source_directories: + raise FileExistsError(relative) + source_directories.add(relative) + if failed: + raise PermissionError("Injected partial source mutation.") + + def scan(path, **kwargs): + relative = relative_path(path) + for directory in sorted(source_directories): + parent, _, name = directory.rpartition("/") + if parent == relative: + yield SimpleNamespace(name=name, smb_info=SimpleNamespace(file_attributes=FileAttributes.FILE_ATTRIBUTE_DIRECTORY)) + + @contextmanager + def open_writer(path, **kwargs): + dispatched.append(path) + assert sqlite_cache.coordinator.pending[-1][1].path == relative_path(path).rpartition("/")[0] + + def write(chunk): + source_directories.add("parent/discovered") + if failed: + raise PermissionError("Injected partial source mutation.") + return len(chunk) + + yield SimpleNamespace(write=write) + + monkeypatch.setattr("app.storage.smb.get_connection_pool", pool) + monkeypatch.setattr("app.storage.smb.smbclient.mkdir", create) + monkeypatch.setattr("app.storage.smb.smbclient.open_file", open_writer) + monkeypatch.setattr("smbclient.scandir", scan) + monkeypatch.setattr(backend, "get_file_info", AsyncMock(return_value=SimpleNamespace(type=FileType.DIRECTORY))) + if operation == "archive": + mutation = _ensure_live_directory(backend, "parent/new/deep") + elif operation == "mixed_archive": + mutation = _ensure_mixed_archive_parent_directories(backend, destination_root="parent", target_path="parent/new/deep/file") + elif operation == "upload": + mutation = backend.write_file("parent/new", BytesIO(b"uploaded")) + else: + mutation = backend.create_directory("parent/new") + if failed: + with pytest.raises(PermissionError, match="partial source mutation"): + await mutation + else: + await mutation + assert backend._build_smb_path("parent/new") in dispatched + assert len(sqlite_cache.coordinator.pending) == 2 * len(dispatched) + sqlite_cache.coordinator.apply_pending() + assert set(index.pending_scan_paths(1)) == {relative_path(path).rpartition("/")[0] for path in dispatched} + assert index.search("parent") == before + sqlite_cache._reconcile_disk_queue() + after = sqlite_cache.search_index("parent new") + assert {row.path for row in after.results} == source_directories + assert after.total_matches == after.directory_count == len(source_directories) + assert after.last_verified_at_ms == before.last_verified_at_ms + assert index.accepted_generation() == 1 + assert index.reconciliation_generation() is None + assert sqlite_cache.coordinator.verification_required + assert not sqlite_cache.coordinator.pending + assert not sqlite_cache.coordinator._producers + assert sqlite_cache.resources.usage().reserved_bytes == 0 + + +def test_transfer_destination_hint_never_inserts_a_file_as_directory(sqlite_cache, monkeypatch): + from smbclient._os import FileAttributes + + from app.api.browser import _add_to_directory_cache + + index = sqlite_cache.index + index.write_paths(["parent"]) + assert index.finish_generation(1) + before = index.search("parent") + manager = SimpleNamespace(get_cache=lambda _connection_id: sqlite_cache) + monkeypatch.setattr("app.services.directory_cache.get_directory_cache_manager", lambda: manager) + + def scan(path, **kwargs): + if path.endswith("\\parent"): + yield SimpleNamespace(name="file.txt", smb_info=SimpleNamespace(file_attributes=0)) + yield SimpleNamespace(name="directory", smb_info=SimpleNamespace(file_attributes=FileAttributes.FILE_ATTRIBUTE_DIRECTORY)) + + monkeypatch.setattr("smbclient.scandir", scan) + _add_to_directory_cache("fixture", "/parent/file.txt") + sqlite_cache.coordinator.apply_pending() + assert index.search("parent") == before + assert index.pending_scan_paths(1) == ["parent"] + sqlite_cache._reconcile_disk_queue() + assert {row.path for row in index.search("parent").results} == {"parent", "parent/directory"} + assert index.search("file.txt").total_matches == 0 + assert index.status().last_verified_at_ms == before.last_verified_at_ms + + +def test_observed_parent_hints_are_transactional_and_do_not_publish_names(index): + from app.services.directory_cache import READ_PARENT_ACTION, DirectoryScanCoordinator, _DirectoryMutation + + index.write_paths(["retained"]) + assert index.finish_generation(1) + candidate = index.begin_verification() + before = index.search("retained unverified") + coordinator = DirectoryScanCoordinator(index) + assert coordinator.enqueue(_DirectoryMutation(READ_PARENT_ACTION, "unverified")) + index._connection().execute( + f"CREATE TRIGGER reject_parent_hint BEFORE INSERT ON scan_queue WHEN NEW.generation_id = {candidate} " + "BEGIN SELECT RAISE(ABORT, 'injected'); END" + ) + with pytest.raises(sqlite3.IntegrityError): + coordinator.apply_pending() + assert index.pending_scan_paths(1) == index.pending_scan_paths(candidate) == [] + assert len(coordinator.pending) == 1 + assert index.search("retained unverified") == before + index._connection().execute("DROP TRIGGER reject_parent_hint") + coordinator.apply_pending() + assert index.pending_scan_paths(1) == index.pending_scan_paths(candidate) == ["unverified"] + index.finish_reconciliation_parent(1, "unverified") + index.finish_scan_parent(candidate, "unverified") + index.queue_observed_parent("unverified") + assert index.pending_scan_paths(1) == index.pending_scan_paths(candidate) == ["unverified"] + index.finish_scan_parent(candidate, "unverified") + assert index.finish_generation(candidate) + selected = index.status() + index.queue_observed_parent("") + assert index.pending_scan_paths(1) == ["unverified"] + assert index.pending_scan_paths(candidate) == [""] + assert index.status() == selected + assert index.search("unverified").total_matches == 0 + + +@pytest.mark.parametrize("overflow", ["entries", "bytes"]) +def test_parent_hints_fence_root_reads_and_share_bounded_observation_limits(index, overflow): + from app.services.directory_cache import READ_PARENT_ACTION, DirectoryScanCoordinator, ScanObservationInvalidated, _DirectoryMutation + + coordinator = DirectoryScanCoordinator(index, maximum_pending=2, maximum_bytes=3) + token = coordinator.begin_read("other") + assert coordinator.enqueue(_DirectoryMutation(READ_PARENT_ACTION, "")) + assert not coordinator.is_current(token) + assert list(coordinator.barriers) == [(1, "", 0)] + assert coordinator.enqueue(_DirectoryMutation(READ_PARENT_ACTION, "a")) + assert list(coordinator.barriers) == [(2, "", 0)] + if overflow == "bytes": + coordinator.maximum_pending += 1 + epoch = coordinator.epoch + assert not coordinator.enqueue(_DirectoryMutation(READ_PARENT_ACTION, "b" if overflow == "entries" else "long")) + assert coordinator.epoch == epoch + 1 + assert coordinator.verification_required + assert coordinator.pending_bytes == 1 + assert len(coordinator.pending) == 2 + assert not coordinator.barriers + assert len(coordinator._producers) == 1 + with pytest.raises(ScanObservationInvalidated): + coordinator.commit_children(token, 1, [("other/late", False)]) + coordinator.apply_pending() + assert index.pending_scan_paths(1) == ["", "a"] + assert index.status().directory_count == 0 + coordinator.end_read(token) + assert not coordinator._producers + + +@pytest.mark.parametrize("directions", [("from",), ("to",), ("to", "from"), ("from", "to"), ("file",)]) +def test_backend_rename_hints_do_not_require_pairing_or_insert_unverified_names(sqlite_cache, monkeypatch, directions): + from smbclient._os import FileAttributes + from smbprotocol.change_notify import FileAction + + index = sqlite_cache.index + index.write_paths(["old", "old/tree", "old/tree/child", "new"]) + assert index.finish_generation(1) + verified = index.status().last_verified_at_ms + actions = { + "from": (FileAction.FILE_ACTION_RENAMED_OLD_NAME, "old\\tree"), + "to": (FileAction.FILE_ACTION_RENAMED_NEW_NAME, "new\\tree"), + "file": (FileAction.FILE_ACTION_RENAMED_NEW_NAME, "new\\regular-file"), + } + + def event(action, path): + return {"action": SimpleNamespace(get_value=lambda: action), "file_name": SimpleNamespace(get_value=lambda: path)} + + def scan(path, **kwargs): + relative = path[len(sqlite_cache._smb_base_path) :].lstrip("\\").replace("\\", "/") + for name in {"new": ["tree", "regular-file"], "new/tree": ["child"]}.get(relative, []): + attributes = 0 if name == "regular-file" else FileAttributes.FILE_ATTRIBUTE_DIRECTORY + yield SimpleNamespace(name=name, smb_info=SimpleNamespace(file_attributes=attributes)) + + monkeypatch.setattr("smbclient.scandir", scan) + sqlite_cache._process_change_events([event(*actions[direction]) for direction in directions]) + sqlite_cache.coordinator.apply_pending() + assert index.search("new/tree regular-file").total_matches == 0 + assert index.search("old/tree").total_matches == (0 if "from" in directions else 2) + assert set(index.pending_scan_paths(1)) == {"old" if direction == "from" else "new" for direction in directions} + sqlite_cache._reconcile_disk_queue() + assert index.search("regular-file").total_matches == 0 + assert index.search("new/tree").total_matches == (0 if directions == ("from",) else 2) + assert index.status().last_verified_at_ms == verified + assert index.reconciliation_generation() is None + + +@pytest.mark.asyncio +async def test_update_loop_discovers_accepted_subtrees_without_full_verification(sqlite_cache, monkeypatch): + from contextlib import asynccontextmanager + + from smbclient._os import FileAttributes + from smbprotocol.change_notify import FileAction + + from app.services import directory_cache + from app.services.directory_cache import _DirectoryMutation + + index = sqlite_cache.index + index.write_paths(["retained"]) + assert index.finish_generation(1) + verified = index.status().last_verified_at_ms + clock = sqlite_cache._last_scan_time + sqlite_cache.coordinator.verification_required = False + assert sqlite_cache.coordinator.enqueue(_DirectoryMutation(FileAction.FILE_ACTION_ADDED, "tree")) + assert sqlite_cache.coordinator.verification_required + reads = [] + ticks = 0 + + def scan(path, **kwargs): + reads.append(path) + assert sqlite_cache._scan_running.is_set() + assert not sqlite_cache._manager_scan_slots.acquire(blocking=False) + children = {"": ["retained", "tree"], "tree": ["deep"], "tree/deep": ["child"]} + relative = path[len(sqlite_cache._smb_base_path) :].lstrip("\\").replace("\\", "/") + for name in children.get(relative, []): + yield SimpleNamespace(name=name, smb_info=SimpleNamespace(file_attributes=FileAttributes.FILE_ATTRIBUTE_DIRECTORY)) + + @asynccontextmanager + async def connected(**kwargs): + yield + + async def pool(): + return SimpleNamespace(get_connection=connected) + + async def tick(_delay): + nonlocal ticks + ticks += 1 + if ticks > 1: + raise asyncio.CancelledError + + monkeypatch.setattr(directory_cache, "asyncio", SimpleNamespace(**(vars(asyncio) | {"sleep": tick}))) + monkeypatch.setattr(directory_cache, "get_connection_pool", pool) + monkeypatch.setattr("smbclient.scandir", scan) + monkeypatch.setattr(sqlite_cache, "_verification_due", lambda _verified_at: False) + monkeypatch.setattr(sqlite_cache, "_run_rescan", lambda: pytest.fail("Reconciliation must not start a full pass.")) + await sqlite_cache._periodic_rescan_loop() + response = index.search("tree retained") + assert {row.path for row in response.results} == {"retained", "tree", "tree/deep", "tree/deep/child"} + assert response.last_verified_at_ms == verified + assert sqlite_cache._last_scan_time == clock + assert sqlite_cache.coordinator.verification_required + assert index.accepted_generation() == 1 + assert index.reconciliation_generation() is None + assert not sqlite_cache.coordinator._producers + assert not sqlite_cache._scan_running.is_set() + assert sqlite_cache._active_workers == 0 + assert sqlite_cache.resources.usage().reserved_bytes == 0 + assert reads == [sqlite_cache._smb_base_path + suffix for suffix in ("", "\\tree", "\\tree\\deep", "\\tree\\deep\\child")] + + +@pytest.mark.parametrize("blocker", ["capacity", "generation", "retry", "unavailable", "stop", "storage"]) +def test_accepted_parent_worker_preserves_work_after_refusal(sqlite_cache, monkeypatch, blocker): + from time import monotonic + + from app.services.directory_index_resources import DirectoryDiskBudgetError + + index = sqlite_cache.index + index.write_paths(["parent"]) + assert index.finish_generation(1) + index.queue_reconciliation_parent(1, "parent") + before = index.search("parent") + reads = [] + + def scan(path, **kwargs): + reads.append(path) + return (entry for entry in ()) + + monkeypatch.setattr("smbclient.scandir", scan) + lock = None + if blocker == "capacity": + lock = sqlite_cache._manager_scan_slots + assert lock.acquire(blocking=False) + elif blocker == "generation": + lock = sqlite_cache._generation_lock + assert lock.acquire(blocking=False) + elif blocker == "retry": + sqlite_cache._schedule_retry(now=monotonic()) + elif blocker == "unavailable": + sqlite_cache._source_available.clear() + elif blocker == "stop": + sqlite_cache._stop_event.set() + else: + + def denied(): + raise DirectoryDiskBudgetError("Injected reconciliation headroom refusal.") + + monkeypatch.setattr(index, "growth_guard", denied) + try: + for _attempt in range(2): + if blocker == "storage": + with pytest.raises(DirectoryDiskBudgetError): + sqlite_cache._reconcile_disk_queue() + else: + sqlite_cache._reconcile_disk_queue() + assert index.pending_scan_paths(1) == ["parent"] + assert index.search("parent") == before + assert not sqlite_cache.coordinator._producers + assert not sqlite_cache._scan_running.is_set() + assert sqlite_cache.resources.usage().reserved_bytes == 0 + assert len(reads) == (2 if blocker == "storage" else 0) + finally: + if lock is not None: + lock.release() + monkeypatch.undo() + sqlite_cache._reset_retry() + sqlite_cache._source_available.set() + sqlite_cache._stop_event.clear() + monkeypatch.setattr("smbclient.scandir", scan) + sqlite_cache._reconcile_disk_queue() + assert index.reconciliation_generation() is None + assert index.search("parent") == before + + +@pytest.mark.parametrize("failure", ["denied", "redirected", "close"]) +def test_accepted_parent_worker_keeps_failed_parent_pending(sqlite_cache, monkeypatch, failure): + from smbclient._os import FileAttributes + + index = sqlite_cache.index + index.write_paths(["parent", "parent/retained"]) + assert index.finish_generation(1) + index.queue_reconciliation_parent(1, "parent") + before = index.search("parent") + + def scan(path, **kwargs): + if failure == "denied": + raise PermissionError("Injected source denial.") + if failure == "redirected": + yield SimpleNamespace( + name="unsafe", + smb_info=SimpleNamespace( + file_attributes=FileAttributes.FILE_ATTRIBUTE_DIRECTORY | FileAttributes.FILE_ATTRIBUTE_REPARSE_POINT + ), + ) + + class FailedClose: + def __next__(self): + raise StopIteration + + def close(self): + raise OSError("Injected source cleanup failure.") + + monkeypatch.setattr("smbclient.scandir", (lambda *args, **kwargs: FailedClose()) if failure == "close" else scan) + with pytest.raises(OSError): + sqlite_cache._reconcile_disk_queue() + assert index.pending_scan_paths(1) == ["parent"] + assert index.search("parent") == before + assert not sqlite_cache.coordinator._producers + assert not sqlite_cache._scan_running.is_set() + assert sqlite_cache._manager_scan_slots.acquire(blocking=False) + sqlite_cache._manager_scan_slots.release() + + +def test_pending_accepted_parent_work_defers_all_maintenance(sqlite_cache, monkeypatch): + index = sqlite_cache.index + index.write_paths(["parent"]) + assert index.finish_generation(1) + index.queue_reconciliation_parent(1, "parent") + before = index.search("parent") + calls = [] + + def complete(): + calls.append(True) + return True + + monkeypatch.setattr(index, "cleanup_retired_batch", lambda: not complete()) + monkeypatch.setattr(index, "reclaim_pages", complete) + monkeypatch.setattr(index, "checkpoint_if_due", complete) + sqlite_cache._cleanup_pending = True + sqlite_cache._reclamation_pending = True + for operation in (sqlite_cache._cleanup_retired_work, sqlite_cache._reclaim_disk_work, sqlite_cache._checkpoint_work): + operation() + assert not calls + assert sqlite_cache._cleanup_pending + assert sqlite_cache._reclamation_pending + assert index.search("parent") == before + index.finish_reconciliation_parent(1, "parent") + for operation in (sqlite_cache._cleanup_retired_work, sqlite_cache._reclaim_disk_work, sqlite_cache._checkpoint_work): + operation() + assert len(calls) == 3 + assert not sqlite_cache._cleanup_pending + assert not sqlite_cache._reclamation_pending + assert index.search("parent") == before + + +def test_blocked_accepted_parent_worker_retains_ownership_and_rejects_late_rows(sqlite_cache, monkeypatch): + from smbclient._os import FileAttributes + + from app.services.directory_cache import ScanObservationInvalidated + + index = sqlite_cache.index + index.write_paths(["parent"]) + assert index.finish_generation(1) + index.queue_reconciliation_parent(1, "parent") + before = index.search("parent") + entered = threading.Event() + release = threading.Event() + failures = [] + + def scan(path, **kwargs): + entered.set() + assert release.wait(timeout=5) + yield SimpleNamespace(name="late", smb_info=SimpleNamespace(file_attributes=FileAttributes.FILE_ATTRIBUTE_DIRECTORY)) + + def reconcile(): + try: + sqlite_cache._reconcile_disk_queue() + except BaseException as error: + failures.append(error) + + monkeypatch.setattr("smbclient.scandir", scan) + worker = threading.Thread(target=reconcile) + worker.start() + try: + assert entered.wait(timeout=5) + sqlite_cache.coordinator.invalidate() + assert len(sqlite_cache.coordinator._producers) == 1 + assert sqlite_cache._scan_running.is_set() + assert not sqlite_cache._manager_scan_slots.acquire(blocking=False) + assert not sqlite_cache._generation_lock.acquire(blocking=False) + assert index.search("parent") == before + finally: + release.set() + worker.join(timeout=5) + assert not worker.is_alive() + assert len(failures) == 1 + assert isinstance(failures[0], ScanObservationInvalidated) + assert index.search("parent") == before + assert index.pending_scan_paths(1) == ["parent"] + assert sqlite_cache.coordinator.verification_required + assert not sqlite_cache.coordinator._producers + assert not sqlite_cache._scan_running.is_set() + assert sqlite_cache._manager_scan_slots.acquire(blocking=False) + sqlite_cache._manager_scan_slots.release() + + +def test_observed_mutations_queue_accepted_parents_atomically(index): + from smbprotocol.change_notify import FileAction + + from app.services.directory_cache import DirectoryScanCoordinator, _DirectoryMutation + + index.write_paths(["old", "old/tree", "old/tree/child", "new"]) + assert index.finish_generation(1) + before = index.search("old new") + coordinator = DirectoryScanCoordinator(index) + mutation = _DirectoryMutation(FileAction.FILE_ACTION_RENAMED_NEW_NAME, "old/tree", "new/tree") + assert coordinator.enqueue(mutation) + index._connection().execute( + "CREATE TRIGGER reject_observed_work BEFORE INSERT ON scan_queue WHEN NEW.path = 'new' BEGIN SELECT RAISE(ABORT, 'injected'); END" + ) + with pytest.raises(sqlite3.IntegrityError): + coordinator.apply_pending() + assert index.search("old new") == before + assert index.pending_scan_paths(1) == [] + assert len(coordinator.pending) == 1 + index._connection().execute("DROP TRIGGER reject_observed_work") + assert coordinator.apply_pending() == 1 + assert index.pending_scan_paths(1) == ["new", "new/tree", "old"] + assert index.search("old new").last_verified_at_ms == before.last_verified_at_ms + for parent in index.pending_scan_paths(1): + index.finish_reconciliation_parent(1, parent) + assert coordinator.enqueue(_DirectoryMutation(FileAction.FILE_ACTION_ADDED, "new/tree")) + assert coordinator.enqueue(_DirectoryMutation(FileAction.FILE_ACTION_REMOVED, "old")) + assert coordinator.apply_pending() == 2 + assert set(index.pending_scan_paths(1)) == {"", "new", "new/tree"} + assert not coordinator.pending + + +def test_added_subtrees_queue_build_and_candidate_work_atomically(index): + candidate = index.begin_verification() + index.mutate_path("add", "moved-in") + for generation in (1, candidate): + assert index.pending_scan_paths(generation) == ["moved-in"] + index.finish_scan_parent(generation, "moved-in") + index.mutate_path("add", "moved-in") + for generation in (1, candidate): + assert index.pending_scan_paths(generation) == ["moved-in"] + index._connection().execute( + "CREATE TRIGGER reject_added_scan_work BEFORE INSERT ON scan_queue WHEN NEW.path = 'abort' " + "BEGIN SELECT RAISE(ABORT, 'injected'); END" + ) + before = index.search("moved-in") + with pytest.raises(sqlite3.IntegrityError): + index.mutate_path("add", "abort") + assert index.search("moved-in") == before + assert index.search("abort").total_matches == 0 + + +def test_added_subtrees_do_not_queue_accepted_generation_or_advance_verification(index): + index.write_paths(["accepted"]) + assert index.finish_generation(1) + before = index.search("accepted") + candidate = index.begin_verification() + index.mutate_path("add", "new") + assert index.pending_scan_paths(1) == [] + assert index.pending_scan_paths(candidate) == ["new"] + assert index.search("accepted new").directory_count == 2 + assert index.search("accepted").last_verified_at_ms == before.last_verified_at_ms + + +def test_direct_child_pages_preserve_original_names_and_byte_bounds(index): + index.write_paths(["root%_/a/one", "root%_/a/two", "root%_/a-b", "root%_/b/child", "root%_-other/leak", "é/x", "e\u0301/y"]) + assert index.direct_child_paths(1, "root%_", maximum_bytes=18) == ["root%_/a", "root%_/a-b"] + assert index.direct_child_paths(1, "root%_", after_path="root%_/a-b", maximum_bytes=18) == ["root%_/b"] + assert index.direct_child_paths(1, "root%_", after_path="root%_/b") == [] + assert index.direct_child_paths(1, "") == ["e\u0301", "root%_", "root%_-other", "é"] + with pytest.raises(ValueError, match="cursor"): + index.direct_child_paths(1, "root%_", after_path="other/child") + with pytest.raises(ValueError, match="byte budget"): + index.direct_child_paths(1, "root%_", maximum_bytes=1) + with pytest.raises(ValueError, match="byte budget"): + index.direct_child_paths(1, "root%_", maximum_bytes=True) + from app.services.directory_index import ROW_BATCH_SIZE + + index.write_paths(f"many/{number:06d}" for number in range(ROW_BATCH_SIZE * 2 + 1)) + cursor = None + count = 0 + while page := index.direct_child_paths(1, "many", after_path=cursor): + assert len(page) <= ROW_BATCH_SIZE + count += len(page) + cursor = page[-1] + assert count == ROW_BATCH_SIZE * 2 + 1 + + +@pytest.mark.parametrize("maximum_bytes", [8, 9, 10, 12, 16, 24, 64]) +def test_direct_child_pages_match_sorted_reference_without_temporary_sort(index, maximum_bytes): + names = ["a", "a!", "a!a", "a!b", "a-b", "a.", "a0", "a !", "a a", "aä", "!a", "!a!", "é", "z" * 20] + index.write_paths(f"parent/{name}/leaf" for name in reversed(names)) + statements = [] + index._connection().set_trace_callback(statements.append) + try: + for after_path in [None, "parent/a", "parent/a-b", "parent/a0"]: + expected = [] + size = 0 + oversized = False + for path in sorted(f"parent/{name}" for name in names): + if after_path is not None and path <= after_path: + continue + if len(path.encode("utf-8")) > maximum_bytes: + oversized = True + break + if size + len(path.encode("utf-8")) > maximum_bytes: + break + expected.append(path) + size += len(path.encode("utf-8")) + if oversized: + with pytest.raises(ValueError, match="byte budget"): + index.direct_child_paths(1, "parent", after_path=after_path, maximum_bytes=maximum_bytes) + else: + assert index.direct_child_paths(1, "parent", after_path=after_path, maximum_bytes=maximum_bytes) == expected + finally: + index._connection().set_trace_callback(None) + assert statements + for sql in statements: + plan = index._connection().execute("EXPLAIN QUERY PLAN " + sql).fetchall() + assert all("TEMP B-TREE" not in row[-1] for row in plan) + assert any("COVERING INDEX" in row[-1] for row in plan) + + +def test_direct_child_pages_stop_before_reading_the_remaining_flat_tree(index): + from app.services.directory_index import ROW_BATCH_SIZE + + row_count = ROW_BATCH_SIZE * 32 + index.write_paths(f"parent/{number:06d}" for number in range(row_count)) + steps = 0 + + def observe_step(): + nonlocal steps + steps += 1 + return False + + index._connection().set_progress_handler(observe_step, 1) + try: + page = index.direct_child_paths(1, "parent") + finally: + index._connection().set_progress_handler(None, 0) + assert len(page) == ROW_BATCH_SIZE + assert page[-1] == f"parent/{ROW_BATCH_SIZE - 1:06d}" + assert steps < row_count + + +def test_missing_subtree_pruning_is_bounded_and_preserves_verification(index): + from app.services.directory_index import ROW_BATCH_SIZE + + paths = ["gone"] + [f"gone/{number:06d}" for number in range(ROW_BATCH_SIZE * 2)] + index.write_paths(paths + ["gone-other", "keep"]) + assert index.finish_generation(1) + before = index.search("keep") + index._connection().executemany("INSERT INTO scan_queue(generation_id, path) VALUES (1, ?)", ((path,) for path in paths)) + remaining = True + passes = 0 + while remaining: + previous = index.status().directory_count + remaining = index.prune_subtree_batch(1, "gone") + after = index.status() + assert 0 < previous - after.directory_count <= ROW_BATCH_SIZE + assert after.last_verified_at_ms == before.last_verified_at_ms + assert index.search("gone-other keep").total_matches == 2 + passes += 1 + assert passes == 3 + assert index.status().directory_count == 2 + assert index.pending_scan_paths(1) == [] + completed = index.search("keep") + assert not index.prune_subtree_batch(1, "gone") + assert index.search("keep") == completed + + +def test_failed_missing_subtree_pruning_rolls_back_rows_queue_and_revision(index): + index.write_paths(["gone", "gone/child", "keep"]) + index._connection().execute("INSERT INTO scan_queue(generation_id, path) VALUES (1, 'gone')") + index._connection().execute("CREATE TRIGGER reject_prune_queue BEFORE DELETE ON scan_queue BEGIN SELECT RAISE(ABORT, 'injected'); END") + before = index.search("gone keep") + with pytest.raises(sqlite3.IntegrityError): + index.prune_subtree_batch(1, "gone") + assert index.search("gone keep") == before + assert index.pending_scan_paths(1) == ["gone"] + candidate = index.begin_verification() + index.discard_generation(candidate) + with pytest.raises(ValueError, match="no longer active"): + index.prune_subtree_batch(candidate, "gone") + with pytest.raises(ValueError): + index.prune_subtree_batch(1, "") + active = index.begin_verification() + index.write_paths(["gone", "gone/child"], generation_id=active) + assert not index.prune_subtree_batch(active, "gone") + assert index.search("gone keep") == before + + +@pytest.mark.parametrize("code", ["unsafe_traversal", "access_denied"]) +def test_scan_issues_preserve_uncertainty_and_block_acceptance(index, code): + index.write_paths(["regular"]) + index.mark_uncertain() + before = index.status() + for count in (2, 3): + getattr(index, f"record_{code}")(1) + after = index.status() + assert after.issue_count == count + assert after.coverage == "uncertain" + assert after.directory_count == before.directory_count + assert after.last_verified_at_ms == before.last_verified_at_ms + assert after.accepted_revision > before.accepted_revision + before = after + assert not index.finish_generation(1) + assert index.status().issue_count == 3 + assert index._connection().execute("SELECT code, count FROM coverage_issues ORDER BY code").fetchall() == sorted( + [ + ("event_loss", 1), + (code, 2), + ] + ) + + +@pytest.mark.parametrize("code", ["unsafe_traversal", "access_denied"]) +def test_failed_scan_issue_rolls_back_issue_coverage_and_revision(index, code): + before = index.status() + index._connection().execute( + "CREATE TRIGGER reject_issue_metadata BEFORE UPDATE ON generations BEGIN SELECT RAISE(ABORT, 'injected'); END" + ) + with pytest.raises(sqlite3.IntegrityError): + getattr(index, f"record_{code}")(1) + assert index.status() == before + assert index._connection().execute("SELECT count(*) FROM coverage_issues").fetchone()[0] == 0 + + +@pytest.mark.parametrize("state", ["accepted", "retired", "missing"]) +@pytest.mark.parametrize("code", ["unsafe_traversal", "access_denied"]) +def test_scan_issue_rejects_inactive_generation(index, state, code): + assert index.finish_generation(1) + if state == "accepted": + generation = 1 + else: + generation = index.begin_verification() + index.discard_generation(generation) + if state == "missing": + while index.cleanup_retired_batch(): + pass + before = index.status() + with pytest.raises(ValueError, match="inactive scan"): + getattr(index, f"record_{code}")(generation) + assert index.status() == before + assert index._connection().execute("SELECT count(*) FROM coverage_issues").fetchone()[0] == 0 + + +@pytest.mark.parametrize("invalidate", [False, True]) +@pytest.mark.parametrize("code", ["unsafe_traversal", "access_denied"]) +def test_scan_issue_rejects_completed_and_invalidated_reads(index, invalidate, code): + from app.services.directory_cache import DirectoryScanCoordinator, ScanObservationInvalidated + + coordinator = DirectoryScanCoordinator(index) + token = coordinator.begin_read("parent") + if invalidate: + coordinator.invalidate() + else: + coordinator.end_read(token) + before = index.status() + with pytest.raises(ScanObservationInvalidated): + getattr(coordinator, f"record_{code}")(token, 1) + assert index.status() == before + assert index._connection().execute("SELECT count(*) FROM coverage_issues").fetchone()[0] == 0 + assert coordinator.verification_required + coordinator.end_read(token) + + +def test_scan_barriers_reject_late_rename_observations(index): + from smbprotocol.change_notify import FileAction + + from app.services.directory_cache import DirectoryScanCoordinator, ScanObservationInvalidated, _DirectoryMutation + + index.write_paths(["alpha", "alpha/child"]) + coordinator = DirectoryScanCoordinator(index) + token = coordinator.begin_read("alpha") + coordinator.enqueue(_DirectoryMutation(FileAction.FILE_ACTION_RENAMED_NEW_NAME, "alpha", "beta")) + assert coordinator.apply_pending() == 1 + with pytest.raises(ScanObservationInvalidated): + coordinator.commit_children(token, 1, [("alpha/stale", True)]) + assert index.search("alpha").total_matches == 0 + assert index.search("beta").total_matches == 2 + + +def test_repeated_prefix_events_coalesce_without_accepting_old_reads(index): + from smbprotocol.change_notify import FileAction + + from app.services.directory_cache import DirectoryScanCoordinator, _DirectoryMutation + + coordinator = DirectoryScanCoordinator(index, maximum_pending=2) + oldest = coordinator.begin_read("alpha") + assert coordinator.enqueue(_DirectoryMutation(FileAction.FILE_ACTION_ADDED, "alpha")) + coordinator.apply_pending() + newer = coordinator.begin_read("alpha") + epoch = coordinator.epoch + for path in ("alpha/child", "alpha", "alpha/next"): + assert coordinator.enqueue(_DirectoryMutation(FileAction.FILE_ACTION_ADDED, path)) + coordinator.apply_pending() + assert coordinator.epoch == epoch + assert list(coordinator.barriers) == [(coordinator.sequence, "alpha", len("alpha"))] + assert coordinator.barrier_bytes == len("alpha") + assert not coordinator.is_current(oldest) + assert not coordinator.is_current(newer) + + +def test_parent_barrier_coalesces_children_and_preserves_unrelated_prefix(index): + from smbprotocol.change_notify import FileAction + + from app.services.directory_cache import DirectoryScanCoordinator, _DirectoryMutation + + coordinator = DirectoryScanCoordinator(index, maximum_pending=3) + token = coordinator.begin_read("alpha/first") + for path in ("alpha/first", "unrelated", "alpha/second", "alpha"): + assert coordinator.enqueue(_DirectoryMutation(FileAction.FILE_ACTION_ADDED, path)) + coordinator.apply_pending() + assert {entry[1] for entry in coordinator.barriers} == {"alpha", "unrelated"} + assert coordinator.barrier_bytes == len("alpha") + len("unrelated") + assert not coordinator.is_current(token) + + +def test_unrelated_mutation_does_not_invalidate_directory_read(index): + from smbprotocol.change_notify import FileAction + + from app.services.directory_cache import DirectoryScanCoordinator, _DirectoryMutation + + coordinator = DirectoryScanCoordinator(index) + token = coordinator.begin_read("alpha") + coordinator.enqueue(_DirectoryMutation(FileAction.FILE_ACTION_ADDED, "unrelated")) + coordinator.commit_children(token, 1, [("alpha/child", False)]) + assert index.search("alpha unrelated").total_matches == 2 + + +def test_observation_overflow_keeps_pending_updates_and_invalidates_producers(index): + from smbprotocol.change_notify import FileAction + + from app.services.directory_cache import DirectoryScanCoordinator, _DirectoryMutation + + coordinator = DirectoryScanCoordinator(index, maximum_pending=1) + token = coordinator.begin_read("alpha") + assert coordinator.enqueue(_DirectoryMutation(FileAction.FILE_ACTION_ADDED, "accepted")) + assert not coordinator.enqueue(_DirectoryMutation(FileAction.FILE_ACTION_ADDED, "lost")) + assert len(coordinator.pending) == 1 + assert not coordinator.is_current(token) + assert coordinator.apply_pending() == 1 + assert index.search("accepted").total_matches == 1 + assert coordinator.verification_required + + +def test_failed_observation_commit_retains_pending_work(index): + from smbprotocol.change_notify import FileAction + + from app.services.directory_cache import DirectoryScanCoordinator, _DirectoryMutation + + index.write_paths(["alpha", "occupied"]) + coordinator = DirectoryScanCoordinator(index) + coordinator.enqueue(_DirectoryMutation(FileAction.FILE_ACTION_RENAMED_NEW_NAME, "alpha", "occupied")) + with pytest.raises(sqlite3.IntegrityError): + coordinator.apply_pending() + assert len(coordinator.pending) == 1 + assert coordinator.pending_bytes > 0 + assert index.search("alpha occupied").total_matches == 2 + + +def test_barrier_retirement_waits_for_all_old_producers_and_pending_mutations(index): + from smbprotocol.change_notify import FileAction + + from app.services.directory_cache import DirectoryScanCoordinator, ScanObservationInvalidated, _DirectoryMutation + + coordinator = DirectoryScanCoordinator(index) + first = coordinator.begin_read("alpha") + second = coordinator.begin_read("alpha") + assert first != second + coordinator.enqueue(_DirectoryMutation(FileAction.FILE_ACTION_ADDED, "alpha")) + coordinator.end_read(first) + coordinator.apply_pending() + assert coordinator.barriers + assert not coordinator.is_current(second) + coordinator.end_read(second) + assert not coordinator.barriers + assert coordinator.barrier_bytes == 0 + with pytest.raises(ScanObservationInvalidated): + coordinator.commit_children(second, 1, [("alpha/stale", False)]) + newer = coordinator.begin_read("beta") + coordinator.enqueue(_DirectoryMutation(FileAction.FILE_ACTION_ADDED, "beta")) + coordinator.end_read(newer) + assert coordinator.barriers + coordinator.apply_pending() + assert not coordinator.barriers + + +def test_invalidated_producer_cannot_publish_after_barrier_retirement(index): + from smbprotocol.change_notify import FileAction + + from app.services.directory_cache import DirectoryScanCoordinator, _DirectoryMutation + + coordinator = DirectoryScanCoordinator(index, maximum_pending=1) + blocked = coordinator.begin_read("alpha") + coordinator.enqueue(_DirectoryMutation(FileAction.FILE_ACTION_ADDED, "accepted")) + assert not coordinator.enqueue(_DirectoryMutation(FileAction.FILE_ACTION_REMOVED, "alpha")) + coordinator.apply_pending() + assert not coordinator.barriers + assert blocked.producer_id in coordinator._producers + assert not coordinator.is_current(blocked) + coordinator.end_read(blocked) + assert not coordinator._producers + assert coordinator.verification_required + + +def test_read_context_releases_producer_after_exception(index): + from app.services.directory_cache import DirectoryScanCoordinator + + coordinator = DirectoryScanCoordinator(index) + with pytest.raises(OSError): + with coordinator.read("alpha") as token: + raise OSError("Injected blocked read failure") + assert not coordinator._producers + assert not coordinator.is_current(token) + + +def test_generation_acceptance_rejects_change_after_last_parent(index): + from smbprotocol.change_notify import FileAction + + from app.services.directory_cache import DirectoryScanCoordinator, ScanObservationInvalidated, _DirectoryMutation + + index.write_paths(["accepted"]) + assert index.finish_generation(1) + generation = index.begin_verification() + coordinator = DirectoryScanCoordinator(index) + baseline = coordinator.begin_read("") + index.seed_scan(generation) + coordinator.commit_children(baseline, generation, [("candidate", False)]) + coordinator.finish_parent(baseline, generation) + coordinator.enqueue(_DirectoryMutation(FileAction.FILE_ACTION_REMOVED, "candidate")) + with pytest.raises(ScanObservationInvalidated): + coordinator.accept_generation(baseline, generation) + assert index.search("accepted").total_matches == 1 + assert coordinator.verification_required + + +def test_generation_acceptance_clears_sticky_verification_only_on_success(index): + from app.services.directory_cache import DirectoryScanCoordinator + + coordinator = DirectoryScanCoordinator(index) + baseline = coordinator.begin_read("") + index.seed_scan(1) + coordinator.commit_children(baseline, 1, [("accepted", False)]) + coordinator.finish_parent(baseline, 1) + assert coordinator.accept_generation(baseline, 1) + assert not coordinator.verification_required + assert index.search("accepted").coverage == "complete" + + +def test_event_loss_persists_uncertain_coverage_without_losing_accepted_rows(index): + from app.services.directory_cache import DirectoryScanCoordinator + + index.write_paths(["accepted"]) + assert index.finish_generation(1) + verified = index.status().last_verified_at_ms + coordinator = DirectoryScanCoordinator(index) + coordinator.invalidate() + coordinator.apply_pending() + response = index.search("accepted") + assert response.total_matches == 1 + assert response.coverage == "uncertain" + assert response.issue_count == 1 + assert response.last_verified_at_ms == verified + revision = response.accepted_revision + coordinator.apply_pending() + assert index.status().accepted_revision == revision + with coordinator.read("accepted") as read: + coordinator.invalidate() + assert not coordinator.is_current(read) + coordinator.apply_pending() + repeated = index.search("accepted") + assert repeated == response + assert coordinator.verification_required + index._connection().execute( + "CREATE TRIGGER reject_unchanged_loss BEFORE UPDATE ON generations BEGIN SELECT RAISE(ABORT, 'injected'); END" + ) + coordinator.invalidate() + coordinator.apply_pending() + assert index.search("accepted") == response + index._connection().execute("DROP TRIGGER reject_unchanged_loss") + + +def test_failed_event_loss_persistence_retains_verification_and_retries(index): + from app.services.directory_cache import DirectoryScanCoordinator + + index.write_paths(["accepted"]) + assert index.finish_generation(1) + before = index.search("accepted") + coordinator = DirectoryScanCoordinator(index) + coordinator.invalidate() + index._connection().execute( + "CREATE TRIGGER reject_loss_revision BEFORE UPDATE ON source_metadata BEGIN SELECT RAISE(ABORT, 'injected'); END" + ) + with pytest.raises(sqlite3.IntegrityError): + coordinator.apply_pending() + assert index.search("accepted") == before + assert index._connection().execute("SELECT count(*) FROM coverage_issues").fetchone()[0] == 0 + assert coordinator.verification_required + assert coordinator._uncertain_pending + index._connection().execute("DROP TRIGGER reject_loss_revision") + coordinator.apply_pending() + response = index.search("accepted") + assert response.coverage == "uncertain" + assert response.issue_count == 1 + assert response.last_verified_at_ms == before.last_verified_at_ms + assert not coordinator._uncertain_pending + + +def test_unresolved_issue_rows_cannot_be_selected_as_complete(index): + index.write_paths(["partial"]) + index.mark_uncertain() + assert not index.finish_generation(1) + response = index.search("partial") + assert response.coverage == "uncertain" + assert response.issue_count == 1 + generation, selected = index.prepare_scan() + assert not selected + assert generation != 1 + + +def test_failed_generation_issue_count_agrees_with_issue_rows(index): + assert not index.finish_generation(1, issue_count=3) + with index._writer_lock: + aggregate = index._connection().execute("SELECT sum(count) FROM coverage_issues WHERE generation_id = 1").fetchone()[0] + assert index.status().issue_count == aggregate == 3 + + +@pytest.mark.parametrize("direct_exception", [False, True]) +def test_watcher_notify_overflow_requests_verification(sqlite_cache, monkeypatch, direct_exception): + from smbprotocol.change_notify import FileSystemWatcher + from smbprotocol.header import NtStatus + + index = sqlite_cache.index + index.write_paths(["accepted", "accepted/child"]) + assert index.finish_generation(1) + before = index.search("accepted") + sqlite_cache.coordinator.verification_required = False + calls = [] + + def receive(request): + if not request.cancelled: + raise _smb_response_error(NtStatus.STATUS_NOTIFY_ENUM_DIR) + + def start(watcher, **kwargs): + calls.append(kwargs) + if direct_exception and len(calls) <= 2: + watcher._t_exc = _smb_response_error(NtStatus.STATUS_NOTIFY_ENUM_DIR) + request = SimpleNamespace( + cancelled=len(calls) > 2, + response={"status": SimpleNamespace(get_value=lambda: NtStatus.STATUS_NOTIFY_ENUM_DIR)}, + ) + watcher._start_response(request) + + monkeypatch.setattr(FileSystemWatcher, "start", start) + monkeypatch.setattr(sqlite_cache, "_watcher_open", SimpleNamespace(connection=SimpleNamespace(receive=receive), file_name="")) + with sqlite_cache.coordinator.read("accepted") as token: + with pytest.raises(ConnectionError, match="observation stopped unexpectedly"): + sqlite_cache._run_watcher_inner() + assert not sqlite_cache.coordinator.is_current(token) + assert sqlite_cache.coordinator.verification_required + assert len(sqlite_cache.coordinator._producers) == 1 + sqlite_cache.coordinator.apply_pending() + response = index.search("accepted") + assert [row.path for row in response.results] == [row.path for row in before.results] + assert response.coverage == "uncertain" + assert response.issue_count == 1 + assert response.last_verified_at_ms == before.last_verified_at_ms + assert sqlite_cache.scan_error is not None + assert len(calls) == 3 + assert not sqlite_cache.coordinator._producers + monkeypatch.setattr(sqlite_cache, "_watcher_open", None) + + from smbclient._os import FileAttributes + + def scan(path, **kwargs): + relative = path[len(sqlite_cache._smb_base_path) :].lstrip("\\").replace("\\", "/") + for name in {"": ["accepted"], "accepted": ["child"]}.get(relative, []): + yield SimpleNamespace(name=name, smb_info=SimpleNamespace(file_attributes=FileAttributes.FILE_ATTRIBUTE_DIRECTORY)) + + monkeypatch.setattr("smbclient.scandir", scan) + sqlite_cache._scan_disk_queue() + recovered = index.search("accepted") + assert [row.path for row in recovered.results] == [row.path for row in before.results] + assert recovered.coverage == "complete" + assert recovered.issue_count == 0 + assert not sqlite_cache.coordinator.verification_required + assert sqlite_cache.scan_error is None + + +def test_watcher_shutdown_does_not_report_observation_loss(sqlite_cache, monkeypatch): + sqlite_cache.index.write_paths(["accepted"]) + assert sqlite_cache.index.finish_generation(1) + before = sqlite_cache.index.search("accepted") + sqlite_cache.coordinator.verification_required = False + sqlite_cache._watcher_ready.set() + + def wait(): + sqlite_cache._stop_event.set() + return None + + monkeypatch.setattr(sqlite_cache, "_watcher_open", object()) + monkeypatch.setattr( + "app.services.directory_cache.FileSystemWatcher", lambda _handle: SimpleNamespace(start=lambda **kwargs: None, wait=wait) + ) + sqlite_cache._run_watcher_inner() + sqlite_cache.coordinator.apply_pending() + assert sqlite_cache.index.search("accepted") == before + assert not sqlite_cache.coordinator.verification_required + assert sqlite_cache.scan_error is None + monkeypatch.setattr(sqlite_cache, "_watcher_open", None) + + +def test_sqlite_source_watcher_gap_invalidates_scan_and_coverage(sqlite_cache, monkeypatch): + from smbclient._os import FileAttributes + + sqlite_cache.index.write_paths(["accepted"]) + assert sqlite_cache.index.finish_generation(1) + token = sqlite_cache.coordinator.begin_read("accepted") + sqlite_cache._observation_lost() + assert not sqlite_cache.coordinator.is_current(token) + sqlite_cache.coordinator.apply_pending() + response = sqlite_cache.search_index("accepted") + assert response.coverage == "uncertain" + assert response.total_matches == 1 + assert sqlite_cache.scan_error is not None + sqlite_cache.coordinator.end_read(token) + for _ in range(3): + sqlite_cache._observation_lost() + sqlite_cache.coordinator.apply_pending() + assert sqlite_cache.search_index("accepted") == response + + def scan(path, **kwargs): + if path == sqlite_cache._smb_base_path: + yield SimpleNamespace(name="accepted", smb_info=SimpleNamespace(file_attributes=FileAttributes.FILE_ATTRIBUTE_DIRECTORY)) + + monkeypatch.setattr("smbclient.scandir", scan) + sqlite_cache._scan_disk_queue() + recovered = sqlite_cache.search_index("accepted") + assert recovered.coverage == "complete" + assert recovered.issue_count == 0 + assert not sqlite_cache.coordinator.verification_required + assert "monitoring is unavailable" in sqlite_cache.scan_error + sqlite_cache._watcher_ready.set() + sqlite_cache._scan_disk_queue() + assert sqlite_cache.scan_error is None + recovered = sqlite_cache.search_index("accepted") + sqlite_cache._observation_lost() + sqlite_cache.coordinator.apply_pending() + lost_again = sqlite_cache.search_index("accepted") + assert lost_again.coverage == "uncertain" + assert lost_again.issue_count == 1 + assert lost_again.accepted_revision > recovered.accepted_revision + assert lost_again.last_verified_at_ms == recovered.last_verified_at_ms + + +def test_sqlite_source_bounds_batch_bytes_without_limiting_coverage(sqlite_cache, monkeypatch): + from smbclient._os import FileAttributes + + sizes = [] + original_commit = sqlite_cache.coordinator.commit_children + + def commit(token, generation, batch, *, reconcile=False): + sizes.append(sum(len(path.encode("utf-8")) for path, _descend in batch)) + original_commit(token, generation, batch, reconcile=reconcile) + + def scan(path, **kwargs): + if path == sqlite_cache._smb_base_path: + for name in ("alpha", "beta", "gamma"): + yield SimpleNamespace(name=name, smb_info=SimpleNamespace(file_attributes=FileAttributes.FILE_ATTRIBUTE_DIRECTORY)) + + monkeypatch.setattr("app.services.directory_cache.MAX_OBSERVATION_BYTES", 9) + monkeypatch.setattr("smbclient.scandir", scan) + monkeypatch.setattr(sqlite_cache.coordinator, "commit_children", commit) + sqlite_cache._scan_disk_queue() + assert sizes == [9, 5] + assert sqlite_cache.search_index("alpha beta gamma").total_matches == 3 + + +def test_rename_barriers_cannot_exceed_observation_limit(index): + from smbprotocol.change_notify import FileAction + + from app.services.directory_cache import DirectoryScanCoordinator, _DirectoryMutation + + coordinator = DirectoryScanCoordinator(index, maximum_pending=1) + baseline = coordinator.begin_read("") + assert coordinator.enqueue(_DirectoryMutation(FileAction.FILE_ACTION_RENAMED_NEW_NAME, "alpha", "beta")) + assert len(coordinator.barriers) <= coordinator.maximum_pending + assert not coordinator.is_current(baseline) + + +def test_subtree_mutations_update_pending_scan_paths_transactionally(index): + index.seed_scan(1) + index.write_scan_children(1, [("alpha", True), ("alpha/child", True), ("other", True)]) + index.finish_scan_parent(1, "alpha") + index.mutate_path("rename", "alpha", "beta") + assert index.pending_scan_paths(1) == ["", "beta", "beta/child", "other"] + index.mutate_path("remove", "beta") + assert index.pending_scan_paths(1) == ["", "other"] + assert index.search("alpha beta").total_matches == 0 + + +def test_scan_queue_rename_collision_rolls_back_directory_changes(index): + index.seed_scan(1) + index.write_scan_children(1, [("alpha", True), ("occupied", True)]) + index.mutate_path("remove", "occupied") + with index._writer_lock, index._connection() as connection: + connection.execute("INSERT INTO scan_queue(generation_id, path) VALUES (1, 'occupied')") + revision = index.status().accepted_revision + with pytest.raises(sqlite3.IntegrityError): + index.mutate_path("rename", "alpha", "occupied") + assert index.search("alpha").total_matches == 1 + assert index.search("occupied").total_matches == 0 + assert index.pending_scan_paths(1) == ["", "alpha", "occupied"] + assert index.status().accepted_revision == revision + + +def test_pending_work_blocks_complete_generation_and_unbounded_batches(index): + from itertools import count + + from app.services.directory_index import ROW_BATCH_SIZE + + index.seed_scan(1) + with pytest.raises(ValueError, match="work remains queued"): + index.finish_generation(1) + observed = [] + + def children(): + for number in count(): + observed.append(number) + yield f"directory{number}", True + + with pytest.raises(ValueError): + index.write_scan_children(1, children()) + assert len(observed) == ROW_BATCH_SIZE + 1 + assert index.search("").directory_count == 0 + index.finish_scan_parent(1, "") + assert index.finish_generation(1) + + +def test_close_retains_process_lease_until_readers_exit(index, monkeypatch): + entered = threading.Event() + release = threading.Event() + closed = threading.Event() + original_connect = index._connect + + def connect(*, readonly=False): + if readonly: + entered.set() + assert release.wait(timeout=5) + return original_connect(readonly=readonly) + + def close(): + index.close() + closed.set() + + monkeypatch.setattr(index, "_connect", connect) + with ThreadPoolExecutor(max_workers=2) as executor: + query = executor.submit(index.search, "alpha") + try: + assert entered.wait(timeout=5) + shutdown = executor.submit(close) + assert not closed.wait(timeout=0.1) + competing = DirectoryIndex(index.path, source_kind=index.source_kind, source_identity=json.loads(index.source_identity)) + with pytest.raises(BlockingIOError): + competing.open() + finally: + release.set() + assert query.result(timeout=5).total_matches == 0 + shutdown.result(timeout=5) + assert closed.is_set() + + +@pytest.fixture +def database(tmp_path): + with closing(sqlite3.connect(tmp_path / "index.sqlite3")) as connection: + connection.executescript((CONTRACT_ROOT / "schema.sql").read_text()) + connection.executescript((CONTRACT_ROOT / "fts.sql").read_text()) + connection.execute("INSERT INTO generations(id, state, coverage) VALUES (1, 'building', 'partial')") + connection.commit() + yield connection + + +def insert_directory(database, path): + database.execute( + "INSERT INTO directories(generation_id, path, path_key, basename_key, depth, has_dot_directory) VALUES (1, ?, ?, ?, ?, 0)", + (path, path, path.rsplit("/", 1)[-1], path.count("/")), + ) + + +def test_schema_identifiers_and_fts(database): + assert database.execute("PRAGMA application_id").fetchone()[0] == APPLICATION_ID + assert database.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION + assert database.execute("PRAGMA auto_vacuum").fetchone()[0] == 2 + insert_directory(database, "alpha/beta") + assert database.execute("SELECT count(*) FROM directory_fts WHERE directory_fts MATCH ?", ('"alpha" OR "beta"',)).fetchone()[0] == 1 + + +def test_fts_counts_and_rollback(database): + insert_directory(database, "alpha") + database.commit() + with pytest.raises(RuntimeError), database: + database.execute("UPDATE directories SET path = 'beta', path_key = 'beta' WHERE path = 'alpha'") + raise RuntimeError("injected failure") + assert database.execute("SELECT count(*) FROM directory_fts WHERE directory_fts MATCH 'alpha'").fetchone()[0] == 1 + assert database.execute("SELECT indexed_count FROM generations").fetchone()[0] == 1 + with database: + database.execute("DELETE FROM generations WHERE id = 1") + assert database.execute("SELECT count(*) FROM directory_fts WHERE directory_fts MATCH 'alpha'").fetchone()[0] == 0 + + +@pytest.mark.parametrize("path", ["", "/alpha", "alpha/", "a//b", "a/./b", "a/../b", "a\x00b"]) +def test_invalid_paths(database, path): + with pytest.raises(sqlite3.IntegrityError): + insert_directory(database, path) + + +@pytest.fixture +def index(tmp_path): + store = DirectoryIndex(tmp_path / "source.sqlite3", source_kind="smb", source_identity={"connection_id": "test", "host": "test"}) + store.open() + try: + yield store + finally: + store.close() + + +def test_shared_normalization_and_terms(): + fixtures = json.loads((CONTRACT_ROOT / "search-fixtures.json").read_text()) + for case in fixtures["normalization"]: + assert normalize_search_text(case["input"]) == case["expected"] + for case in fixtures["queries"]: + assert search_terms(case["query"]) == tuple(sorted(case["terms"])) + + +def test_shared_search_scan_and_fts_parity(index): + fixtures = json.loads((CONTRACT_ROOT / "search-fixtures.json").read_text()) + index.write_paths(fixtures["paths"]) + for case in fixtures["searches"]: + for force_scan in (False, True): + response = index.search(case["query"], include_dot_directories=case["include_dot"], force_scan=force_scan) + assert response.total_matches == case["count"] + assert [row.path for row in response.results] == case["results"] + from app.services.directory_index import ROW_BATCH_SIZE + + index.write_paths(f"single/{number}/needleone" for number in range(ROW_BATCH_SIZE)) + index.write_paths(["last/needleone-needletwo"]) + for force_scan in (False, True): + response = index.search("needleone needletwo", limit=1, force_scan=force_scan) + assert response.total_matches == ROW_BATCH_SIZE + 1 + assert [row.path for row in response.results] == ["last/needleone-needletwo"] + assert response.results[0].matched_term_count == 2 + + +def test_pending_scan_reads_do_not_walk_processed_prefix_or_sort_pending_jobs(index): + from app.services.directory_index import ROW_BATCH_SIZE, transaction + + connection = index._connection() + with transaction(connection): + connection.executemany( + "INSERT INTO scan_queue(generation_id, path, processed) VALUES (1, ?, 1)", + ((f"done-{number:06d}",) for number in range(ROW_BATCH_SIZE * 32)), + ) + connection.executemany( + "INSERT INTO scan_queue(generation_id, path, processed) VALUES (1, ?, 0)", + ((f"pending-{number:06d}",) for number in range(ROW_BATCH_SIZE)), + ) + steps = 0 + + def observe_step(): + nonlocal steps + steps += 1 + return False + + connection.set_progress_handler(observe_step, 1) + try: + assert index.pending_scan_paths(1, limit=1) == ["pending-000000"] + finally: + connection.set_progress_handler(None, 0) + assert steps < ROW_BATCH_SIZE + + +def test_candidate_failure_preserves_accepted_data(index): + index.write_paths(["old/alpha"]) + assert index.finish_generation(1) + accepted = index.search("alpha") + candidate = index.begin_verification() + index.write_paths(["new/beta"], generation_id=candidate) + assert not index.finish_generation(candidate, issue_count=1) + assert index.search("alpha") == accepted + index.write_paths(["other/alpha"]) + assert index.search("alpha").total_matches == 2 + assert index.search("alpha").last_verified_at_ms == accepted.last_verified_at_ms + + +def test_candidate_selection_is_atomic_and_reopens(index): + index.write_paths(["old/alpha"]) + index.finish_generation(1) + candidate = index.begin_verification() + index.write_paths(["new/beta"], generation_id=candidate) + assert index.search("beta").total_matches == 0 + assert index.finish_generation(candidate) + assert index.search("alpha").total_matches == 0 + assert index.search("beta").total_matches == 1 + index.close() + index.open() + assert index.search("beta").directory_count == 1 + + +def test_source_kind_and_identity_must_match(index): + index.close() + other = DirectoryIndex(index.path, source_kind="local", source_identity={"connection_id": "test", "host": "test"}) + with pytest.raises(ValueError, match="source or policy"): + other.open() + with pytest.raises(RuntimeError, match="not initialized"): + other.search("alpha") + + +def test_late_scan_cannot_write_completed_generation(index): + index.write_paths(["alpha"]) + index.finish_generation(1) + with pytest.raises(ValueError, match="no longer active"): + index.write_paths(["late/alpha"], generation_id=1) + assert index.search("alpha").total_matches == 1 + + +def test_integer_overflow_rolls_back(database): + with database: + database.execute("UPDATE generations SET indexed_count = ?", ((1 << 63) - 1,)) + with pytest.raises(sqlite3.IntegrityError), database: + insert_directory(database, "alpha") + assert database.execute("SELECT count(*) FROM directories").fetchone()[0] == 0 + + +def test_literal_operators_and_colliding_keys(index): + index.write_paths(['a%/_*/"quoted"', "Straße", "STRASSE"]) + assert index.search('a% _* "quoted"').total_matches == 1 + assert index.search("strasse").total_matches == 2 + + +def test_cancelled_query_has_no_success_response(index): + cancelled = threading.Event() + cancelled.set() + with pytest.raises(InterruptedError): + index.search("alpha", cancelled=cancelled) + + +def test_input_limits_are_not_silent(): + with pytest.raises(ValueError, match="UTF-8 bytes"): + search_terms("x" * (MAX_QUERY_BYTES + 1)) + with pytest.raises(ValueError, match="distinct terms"): + search_terms(" ".join(f"term{number}" for number in range(MAX_QUERY_TERMS + 1))) + + +@pytest.mark.parametrize( + "count", + [ + 100_001, + pytest.param( + 1_000_001, + marks=pytest.mark.skipif( + os.environ.get("DIRECTORY_INDEX_CAPACITY_TEST") != "1", + reason="Set DIRECTORY_INDEX_CAPACITY_TEST=1 to run the separate million-path capacity check.", + ), + ), + ], +) +def test_coverage_above_old_cutoff_and_bounded_results(index, count): + index.write_paths(f"directory-{number:06d}" for number in range(count)) + managed_bytes = sum(path.stat().st_size for path in index.path.parent.iterdir() if path.is_file()) + print(f"directory_index_fixture rows={count} managed_bytes={managed_bytes}") + for query in ["directory", "ry"]: + response = index.search(query, limit=3) + assert response.directory_count == response.total_matches == count + assert len(response.results) == 3 + assert index.search("100000").results[0].path == "directory-100000" + assert index.finish_generation(1) + verified = index.search("").last_verified_at_ms + candidate = index.begin_verification() + index.write_paths((f"directory-{number:06d}" for number in range(count)), generation_id=candidate) + assert index.search("directory").directory_count == count + index.mutate_path("rename", "directory-100000", "directory-updated") + assert index.search("100000").total_matches == 0 + assert index.search("updated").results[0].path == "directory-updated" + assert index.search("").last_verified_at_ms == verified + assert index.finish_generation(candidate) + index.close() + index.open() + assert index.search("directory").directory_count == index.search("directory").total_matches == count + assert index.search("100000").total_matches == 0 + assert index.search("updated").results[0].path == "directory-updated" + + +@pytest.mark.skipif( + os.environ.get("DIRECTORY_INDEX_HEADROOM_MEASUREMENT") != "1" or not Path("/proc/self/fd").is_dir(), + reason="Explicit Linux operation-headroom measurement; not part of routine tests.", +) +def test_directory_operation_headroom_measurement(index): + from app.services.directory_index import transaction + from app.services.directory_index_resources import WRITE_HEADROOM_BYTES, DirectoryIndexResources + + resources = DirectoryIndexResources(index.path.parent) + resources.register_source(index.path) + index.growth_guard = resources.reserve_growth + peaks = {"managed_bytes": 0, "sql_deleted_bytes": 0, "all_deleted_bytes": 0, "descriptors": 0, "rss_kib": 0} + + def sample(): + peaks["managed_bytes"] = max(peaks["managed_bytes"], resources.usage().managed_bytes) + sql_deleted = 0 + all_deleted = 0 + descriptors = tuple(Path("/proc/self/fd").iterdir()) + peaks["descriptors"] = max(peaks["descriptors"], len(descriptors)) + for descriptor in descriptors: + try: + target = os.readlink(descriptor) + metadata = descriptor.stat() + except FileNotFoundError: + continue + if target.endswith(" (deleted)") and stat.S_ISREG(metadata.st_mode): + all_deleted += metadata.st_size + if "etilqs" in target: + sql_deleted += metadata.st_size + peaks["sql_deleted_bytes"] = max(peaks["sql_deleted_bytes"], sql_deleted) + peaks["all_deleted_bytes"] = max(peaks["all_deleted_bytes"], all_deleted) + with Path("/proc/self/status").open() as status: + rss = next(int(line.split()[1]) for line in status if line.startswith("VmRSS:")) + peaks["rss_kib"] = max(peaks["rss_kib"], rss) + return False + + initial_managed = resources.usage().managed_bytes + sample() + initial_rss = peaks["rss_kib"] + index._connection().set_progress_handler(sample, 1000) + second = DirectoryIndex(index.path.with_name("second.sqlite3"), source_kind="smb", source_identity={"root": "second"}) + try: + second.open() + resources.register_source(second.path) + second.growth_guard = resources.reserve_growth + second._connection().set_progress_handler(sample, 1000) + paths = tuple(f"operation/{number:04d}/repeated-token-{number:04d}" for number in range(4096)) + index.write_paths(paths) + assert index.finish_generation(1) + second.write_paths(paths) + assert second.finish_generation(1) + sample() + with closing(index._connect(readonly=True)) as reader, transaction(reader): + old_count = reader.execute("SELECT COUNT(*) FROM directories").fetchone()[0] + candidate = index.begin_verification() + index.write_paths(paths, generation_id=candidate) + index.write_paths(["operation/new"]) + assert reader.execute("SELECT COUNT(*) FROM directories").fetchone() == (old_count,) + index.checkpoint() + before = index.search("operation repeated") + with transaction(index._connection()) as writer: + writer.execute("INSERT INTO directory_fts(directory_fts) VALUES ('delete-all')") + index._fts_ready = False + index._fts_repair_cursor = None + while index.repair_fts_batch(): + sample() + assert index.search("operation repeated") == before + sample() + assert index.finish_generation(candidate) + while index.cleanup_retired_batch(): + sample() + index.reclaim_pages() + second.close() + resources.release_source(second.path) + second._acquire_storage() + try: + second._quarantine_owned() + with resources.reserve_growth(), resources.reserve_growth(): + sample() + assert resources.usage().reserved_bytes == 2 * WRITE_HEADROOM_BYTES + finally: + second._release_storage() + sample() + print( + "DIRECTORY_INDEX_HEADROOM_MEASUREMENT " + + json.dumps( + { + "rows_per_source": len(paths), + "initial_managed_bytes": initial_managed, + "initial_rss_kib": initial_rss, + "reservation_overlap_bytes": 2 * WRITE_HEADROOM_BYTES, + **peaks, + }, + sort_keys=True, + ), + flush=True, + ) + finally: + index._connection().set_progress_handler(None, 0) + second.close() + resources.release_source(second.path) + resources.release_source(index.path) + + +@pytest.mark.skipif( + os.environ.get("DIRECTORY_INDEX_APPLICATION_MEASUREMENT") != "1", + reason="Explicit injected application resource measurement; not part of routine tests.", +) +def test_injected_application_capacity_measurement(sqlite_cache, monkeypatch): + import platform + import resource + + from smbclient._os import FileAttributes + + count = int(os.environ.get("DIRECTORY_INDEX_MEASURE_COUNT", "100000")) + assert count >= 100_000 + cache = sqlite_cache + index = cache.index + started = time.perf_counter() + finished = threading.Event() + first_partial = [] + peaks: dict[str, int] = dict.fromkeys( + ("rss_kib", "fds", "managed_bytes", "main_bytes", "wal_bytes", "shm_bytes", "producers", "pending"), 0 + ) + failures = [] + metrics = {"rows": count, "python": platform.python_version(), "sqlite": sqlite3.sqlite_version, "host": platform.platform()} + + def rss(): + with Path("/proc/self/status").open() as status: + return next(int(line.split()[1]) for line in status if line.startswith("VmRSS:")) + + def entries(path, **_kwargs): + if path == cache._smb_base_path: + return ( + SimpleNamespace( + name=f"directory-{number:06d}", smb_info=SimpleNamespace(file_attributes=FileAttributes.FILE_ATTRIBUTE_DIRECTORY) + ) + for number in range(count) + ) + return (entry for entry in ()) + + def monitor(): + try: + while not finished.is_set(): + peaks["rss_kib"] = max(peaks["rss_kib"], rss()) + peaks["fds"] = max(peaks["fds"], len(tuple(Path("/proc/self/fd").iterdir()))) + peaks["managed_bytes"] = max(peaks["managed_bytes"], cache.resources.usage().managed_bytes) + for key, suffix in (("main_bytes", ""), ("wal_bytes", "-wal"), ("shm_bytes", "-shm")): + try: + peaks[key] = max(peaks[key], Path(str(index.path) + suffix).stat().st_size) + except FileNotFoundError: + pass + with cache.coordinator.lock: + peaks["producers"] = max(peaks["producers"], len(cache.coordinator._producers)) + peaks["pending"] = max(peaks["pending"], len(cache.coordinator.pending)) + if not first_partial: + response = cache.search_index("000099", 3) + if response.results: + first_partial.append( + { + "seconds": time.perf_counter() - started, + "coverage": response.coverage, + "directory_count": response.directory_count, + } + ) + finished.wait(0.1) + except BaseException as error: + failures.append(error) + + monkeypatch.setattr("smbclient.scandir", entries) + sampler = threading.Thread(target=monitor) + metrics.update(cache_target_kib_per_connection=cache.policy.page_cache_kib, initial_rss_kib=rss()) + sampler.start() + try: + cpu = time.process_time() + begin = time.perf_counter() + cache._scan_disk_queue() + metrics.update(build_seconds=time.perf_counter() - begin, build_cpu_seconds=time.process_time() - cpu) + assert index.status().directory_count == count + print("DIRECTORY_INDEX_BUILD " + json.dumps(metrics, sort_keys=True), flush=True) + for label, query in (("common", "directory"), ("mixed_short", "ry 090000")): + begin = time.perf_counter() + response = cache.search_index(query, 3) + metrics[label + "_query_ms"] = (time.perf_counter() - begin) * 1000 + assert response.total_matches == count and len(response.results) == 3 + begin = time.perf_counter() + with ThreadPoolExecutor(max_workers=1) as executor: + verification = executor.submit(cache._scan_disk_queue) + response = cache.search_index("090000", 3) + metrics["query_during_verification_ms"] = (time.perf_counter() - begin) * 1000 + assert response.directory_count == count and response.total_matches == 1 + verification.result() + metrics["verification_seconds"] = time.perf_counter() - begin + cache.apply_policy(cache.policy.model_copy(update={"page_cache_kib": 64})) + response = cache.search_index("ry 090000", 3) + assert response.directory_count == response.total_matches == count + metrics.update(small_cache_count=response.total_matches, small_cache_kib=cache.policy.page_cache_kib) + metrics["first_partial"] = first_partial + finally: + finished.set() + sampler.join() + cache.stop() + assert not failures + begin = time.perf_counter() + reopened = DirectoryIndex(index.path, source_kind=index.source_kind, source_identity=json.loads(index.source_identity)) + reopened.open() + try: + response = reopened.search("090000", limit=3) + assert response.directory_count == count + assert response.total_matches == 1 + metrics["storage_reopen_seconds"] = time.perf_counter() - begin + finally: + reopened.close() + metrics.update( + sampled_peaks=peaks, + process_peak_rss_kib=resource.getrusage(resource.RUSAGE_SELF).ru_maxrss, + total_seconds=time.perf_counter() - started, + ) + print("DIRECTORY_INDEX_APPLICATION_MEASUREMENT " + json.dumps(metrics, sort_keys=True), flush=True) + + +def test_bulk_path_writes_bound_bytes_before_building_row_keys(index, monkeypatch): + from contextlib import contextmanager + + monkeypatch.setattr("app.services.directory_index.SCAN_BATCH_BYTES", 32) + counts = [] + + @contextmanager + def observe_batch(): + yield + counts.append(index._connection().execute("SELECT count(*) FROM directories").fetchone()[0]) + + index.growth_guard = observe_batch + index.write_paths(f"directory-{number:06d}" for number in range(5)) + assert counts == [2, 4, 5] + with pytest.raises(ValueError, match="scan-batch byte limit"): + index.write_paths(["x" * 33]) + assert index.search("directory").directory_count == 5 + + +def test_mutations_update_accepted_candidate_fts_and_preserve_verification(index): + index.write_paths(["parent/alpha", "parent/alpha/child", "parent/alpha-other"]) + index.finish_generation(1) + before = index.search("alpha") + candidate = index.begin_verification() + index.write_paths(["parent/alpha", "parent/alpha/child", "parent/alpha-other"], generation_id=candidate) + index.mutate_path("rename", "parent/alpha", "new/.beta") + assert index.search("alpha").total_matches == 1 + assert index.search("beta").total_matches == 0 + assert index.search("beta", include_dot_directories=True).total_matches == 2 + assert index.search("beta", include_dot_directories=True).last_verified_at_ms == before.last_verified_at_ms + index.mutate_path("remove", "new/.beta") + index.mutate_path("add", "fresh/beta") + assert index.search("beta").total_matches == 1 + with pytest.raises(ValueError, match="Scan work remains queued"): + index.finish_generation(candidate) + assert index.pending_scan_paths(candidate) == ["fresh/beta"] + index.finish_scan_parent(candidate, "fresh/beta") + index.finish_generation(candidate) + assert index.search("beta").results[0].path == "fresh/beta" + assert index.search("alpha").total_matches == 1 + + +@pytest.mark.parametrize("identifier", [-(1 << 63), -1, 0], ids=["minimum-signed-id", "negative-id", "zero-id"]) +def test_rename_includes_nonpositive_row_ids(index, identifier): + index.write_paths(["alpha", "alpha/child"]) + index._connection().execute("UPDATE directories SET id = ? WHERE path = 'alpha'", (identifier,)) + index.finish_generation(1) + before = index.search("alpha") + + index.mutate_path("rename", "alpha", "beta") + + after = index.search("beta") + assert {result.path for result in after.results} == {"beta", "beta/child"} + assert after.directory_count == after.total_matches == before.directory_count == 2 + assert after.last_verified_at_ms == before.last_verified_at_ms + assert index.search("alpha").total_matches == 0 + assert index._connection().execute("SELECT id FROM directories WHERE path = 'beta'").fetchone() == (identifier,) + + +def test_rename_conflict_rolls_back_rows_counts_and_revision(index): + index.write_paths(["alpha", "beta"]) + before = index.search("alpha beta") + with pytest.raises(sqlite3.IntegrityError): + index.mutate_path("rename", "alpha", "beta") + assert index.search("alpha beta") == before + + +def test_scan_queue_is_disk_backed_and_deduplicated(index): + index.seed_scan(1) + assert index.pending_scan_paths(1) == [""] + index.write_scan_children(1, [("alpha", True), ("link", False)]) + index.write_scan_children(1, [("alpha", True)]) + index.finish_scan_parent(1, "") + assert index.pending_scan_paths(1) == ["alpha"] + index.finish_scan_parent(1, "alpha") + assert index.pending_scan_paths(1) == [] + assert index.status().directory_count == 2 + + +def test_scan_queue_failure_rolls_back_directory_rows(index): + index._connection().execute("CREATE TRIGGER fail_queue BEFORE INSERT ON scan_queue BEGIN SELECT RAISE(ABORT, 'injected'); END") + with pytest.raises(sqlite3.IntegrityError, match="injected"): + index.write_scan_children(1, [("alpha", True)]) + assert index.search("alpha").total_matches == 0 + assert index.status().accepted_revision == 0 + + +def test_process_lease_prevents_two_writers(index): + second = DirectoryIndex(index.path, source_kind="smb", source_identity={"connection_id": "test", "host": "test"}) + with pytest.raises(BlockingIOError): + second.open() + index.close() + second.open() + second.close() + + +def test_private_storage_rejects_links_and_public_permissions(tmp_path): + public = tmp_path / "public" + public.mkdir(mode=0o755) + store = DirectoryIndex(public / "source.sqlite3", source_kind="local", source_identity={"root": "test"}) + with pytest.raises(PermissionError, match="0700"): + store.open() + link = tmp_path / "link" + link.symlink_to(public, target_is_directory=True) + store = DirectoryIndex(link / "source.sqlite3", source_kind="local", source_identity={"root": "test"}) + with pytest.raises(PermissionError, match="symbolic"): + store.open() + + +def test_open_failure_releases_process_lease(index, monkeypatch): + index.close() + with monkeypatch.context() as context: + context.setattr(index, "_connect", lambda **_kwargs: (_ for _ in ()).throw(sqlite3.OperationalError("injected"))) + with pytest.raises(sqlite3.OperationalError, match="injected"): + index.open() + assert index._lease_fd is None + index.open() diff --git a/backend/tests/test_directory_monitor.py b/backend/tests/test_directory_monitor.py index 30d04fd4b..236bdff43 100644 --- a/backend/tests/test_directory_monitor.py +++ b/backend/tests/test_directory_monitor.py @@ -10,6 +10,9 @@ - Thread safety """ +import asyncio +import io +import logging import threading from unittest.mock import AsyncMock, MagicMock, patch @@ -17,12 +20,141 @@ from smbprotocol.exceptions import SMBOSError from smbprotocol.header import NtStatus +from app.core.logging import RecoveryLogFormatter from app.services.directory_monitor import DirectoryMonitor, MonitoredDirectory +def test_watcher_failure_has_one_safe_cause_until_recovery(monkeypatch): + output = io.StringIO() + logger = logging.Logger("app.services.directory_monitor", level=logging.DEBUG) + handler = logging.StreamHandler(output) + handler.setFormatter(RecoveryLogFormatter("%(message)s")) + logger.addHandler(handler) + monkeypatch.setattr("app.services.directory_monitor.logger", logger) + monitor = MonitoredDirectory( + "private-connection", "private-path", "private-host", "private-share", "private-user", "private-password", 445 + ) + try: + raise OSError("private-path private-password") + except OSError as error: + for _attempt in range(10): + monitor._report_watcher_failure(error) + logs = output.getvalue() + assert logs.count("event='directory_watcher_unavailable'") == 1 + assert logs.count("Recovery traceback:") == 1 + for private_value in (monitor.connection_id, monitor.path, monitor.host, monitor.share_name, monitor.username, monitor.password): + assert private_value not in logs + assert "source_id=" in logs + assert "generation_id=" in logs + with patch("app.services.directory_monitor.FileSystemWatcher"): + monitor._handle_change_result([]) + monitor._handle_change_result([]) + assert output.getvalue().count("event='directory_watcher_recovered'") == 1 + monitor._report_watcher_failure(OSError("another private-path")) + assert output.getvalue().count("event='directory_watcher_unavailable'") == 2 + + class TestDirectoryMonitorLifecycle: """Test directory monitor lifecycle operations.""" + @pytest.mark.asyncio + async def test_subscription_during_restart_reuses_its_desired_monitor(self): + manager = DirectoryMonitor() + monitor = MonitoredDirectory("conn", "/", "host", "share", "user", "pass", 445) + manager._monitors["conn:/"] = monitor + entered = threading.Event() + release = threading.Event() + + def stop_worker(): + monitor._stop_event.set() + entered.set() + assert release.wait(3) + return True + + with patch.object(monitor, "_stop_locked", side_effect=stop_worker), patch.object(monitor, "start") as start: + restarting = asyncio.create_task(asyncio.to_thread(manager.restart_all)) + try: + assert await asyncio.to_thread(entered.wait, 2) + with patch("app.services.directory_monitor.MonitoredDirectory") as replacement: + acquired = manager.start_monitoring("conn", "/", "host", "share", "user", "pass") + assert acquired is monitor + assert monitor.subscriber_count == 2 + replacement.assert_not_called() + finally: + release.set() + await asyncio.wait_for(restarting, 2) + start.assert_called_once() + assert manager._monitors["conn:/"] is monitor + assert not monitor.stopped + + @pytest.mark.asyncio + @pytest.mark.parametrize("retirement", [None, "shutdown", "replacement"]) + async def test_cancelled_start_retains_ownership_and_cannot_release_replacement(self, retirement): + manager = DirectoryMonitor() + started = threading.Event() + finish = threading.Event() + acquired = MagicMock(subscriber_count=1) + acquired.stopped = False + replacement = MagicMock(subscriber_count=1) + + def start(): + started.set() + assert finish.wait(3) + + acquired.start.side_effect = start + with patch("app.services.directory_monitor.MonitoredDirectory", return_value=acquired): + starting = asyncio.create_task(manager.start_monitoring_async("conn", "/", "host", "share", "user", "pass")) + try: + assert await asyncio.to_thread(started.wait, 2) + starting.cancel() + with pytest.raises(asyncio.CancelledError): + await starting + assert len(manager._startup_cleanups) == 1 + acquired.stop.assert_not_called() + if retirement == "shutdown": + manager.stop_all() + elif retirement == "replacement": + manager.stop_connection("conn") + manager._monitors["conn:/"] = replacement + finally: + finish.set() + await asyncio.wait_for(asyncio.gather(*tuple(manager._startup_cleanups)), 2) + acquired.stop.assert_called_once() + replacement.stop.assert_not_called() + assert manager._monitors == ({"conn:/": replacement} if retirement == "replacement" else {}) + assert manager._starting == {} + + def test_shutdown_rejects_new_monitor_start(self): + manager = DirectoryMonitor() + manager.stop_all() + with patch("app.services.directory_monitor.MonitoredDirectory") as monitor_type: + with pytest.raises(RuntimeError, match="shutting down"): + manager.start_monitoring("conn", "/", "host", "share", "user", "pass") + monitor_type.assert_not_called() + + def test_terminal_restart_failure_retires_registration(self): + monitor = MonitoredDirectory("conn", "/", "host", "share", "user", "pass", 445) + monitor.on_stopped = MagicMock() + with patch.object(monitor, "_stop_locked", return_value=True), patch.object(monitor, "start", side_effect=FileNotFoundError): + with pytest.raises(FileNotFoundError): + monitor.restart() + assert monitor.stopped + assert not monitor._restarting + monitor.on_stopped.assert_called_once() + + def test_new_worker_exit_during_restart_retires_registration(self): + monitor = MonitoredDirectory("conn", "/", "host", "share", "user", "pass", 445) + monitor.on_stopped = MagicMock() + + def finish_new_worker(): + monitor._stop_event.set() + monitor._monitor_loop() + + with patch.object(monitor, "_stop_locked", return_value=True), patch.object(monitor, "start", side_effect=finish_new_worker): + monitor.restart() + assert monitor.stopped + monitor.on_stopped.assert_called_once() + @pytest.mark.parametrize("error_status", [NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, NtStatus.STATUS_OBJECT_PATH_NOT_FOUND]) def test_missing_monitor_target_is_translated_and_released(self, error_status, caplog): monitor = DirectoryMonitor() @@ -268,6 +400,44 @@ def test_monitor_multiple_directories(self, mock_monitored_dir): class TestMonitoredDirectoryLifecycle: """Test MonitoredDirectory class lifecycle.""" + def test_watcher_failure_clears_old_watcher_before_reconnect(self): + monitored = MonitoredDirectory("conn", "/", "host", "share", "user", "pass", 445) + watcher = MagicMock() + watcher.wait.side_effect = RuntimeError("Opaque failure without transport keywords") + monitored._watcher = watcher + + def reconnect(): + assert monitored._watcher is None + monitored._stop_event.set() + + with patch.object(monitored, "_reconnect", side_effect=reconnect) as recover: + monitored._monitor_loop() + recover.assert_called_once() + + @pytest.mark.parametrize("restart", [False, True]) + def test_active_worker_retains_resources_after_stop_deadline(self, restart): + monitored = MonitoredDirectory("conn", "/", "host", "share", "user", "pass", 445) + monitored._monitor_thread = MagicMock() + monitored._monitor_thread.is_alive.return_value = True + with patch.object(monitored, "_cleanup") as cleanup, patch.object(monitored, "start") as start: + if restart: + monitored.restart() + else: + monitored.stop() + cleanup.assert_not_called() + start.assert_not_called() + assert monitored.stopped + + @patch("app.services.directory_monitor.MonitoredDirectory") + def test_stopped_registration_is_not_reused(self, monitor_type): + manager = DirectoryMonitor() + stopped = MagicMock() + stopped.stopped = True + manager._monitors["conn:/"] = stopped + manager.start_monitoring("conn", "/", "host", "share", "user", "pass") + monitor_type.return_value.start.assert_called_once() + assert manager._monitors["conn:/"] is monitor_type.return_value + def test_monitored_directory_initialization(self): """Test MonitoredDirectory initialization.""" callback = AsyncMock() @@ -399,8 +569,11 @@ def test_start_monitoring_connection_failure(self, mock_session, mock_connection port=445, ) - with pytest.raises(Exception, match="Connection failed"): + with patch("app.services.directory_monitor.threading.Thread") as thread: monitored.start() + thread.return_value.start.assert_called_once() + assert not monitored._stop_event.is_set() + monitored.stop() @patch("app.services.directory_monitor.threading.Thread") @patch("app.services.directory_monitor.Connection") @@ -462,6 +635,34 @@ def test_stop_monitoring_cleanup( class TestChangeNotifications: """Test change notification handling.""" + def test_failed_reconnect_remains_active_until_unsubscribe(self): + monitored = MonitoredDirectory("conn-123", "documents", "server", "share", "user", "password", 445) + attempts = 0 + + def reconnect(): + nonlocal attempts + attempts += 1 + if attempts == 3: + monitored._stop_event.set() + raise ConnectionError("Source is unavailable") + + retired = MagicMock() + monitored.on_stopped = retired + with patch.object(monitored, "_reconnect", side_effect=reconnect), patch.object(monitored, "_cleanup"): + monitored._monitor_loop() + assert attempts == 3 + retired.assert_called_once() + + def test_retry_exhaustion_keeps_bounded_recovery_active(self): + from app.services.directory_monitor import MAX_RETRY_ATTEMPTS, MAX_RETRY_DELAY + + monitored = MonitoredDirectory("conn-123", "documents", "server", "share", "user", "password", 445) + monitored._consecutive_failures = MAX_RETRY_ATTEMPTS + 1 + with patch.object(monitored._stop_event, "wait", return_value=True) as wait: + monitored._reconnect() + assert 0 < wait.call_args.args[0] <= MAX_RETRY_DELAY + assert not monitored._stop_event.is_set() + @pytest.mark.parametrize("status", [NtStatus.STATUS_DELETE_PENDING, NtStatus.STATUS_OBJECT_PATH_NOT_FOUND]) def test_deleted_watch_invalidates_and_retires(self, status, caplog): callback = AsyncMock() @@ -635,6 +836,7 @@ class MockMonitor: def __init__(self): self.subscriber_count = 1 self.start_called = False + self.stopped = False def start(self): self.start_called = True diff --git a/backend/tests/test_directory_search.py b/backend/tests/test_directory_search.py index 84be462b7..fa5230986 100644 --- a/backend/tests/test_directory_search.py +++ b/backend/tests/test_directory_search.py @@ -5,15 +5,29 @@ _update_directory_cache_from_listing helper function. """ +import asyncio +import os +import sqlite3 +import statistics +import threading +import time import uuid +from dataclasses import replace +from math import ceil from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi.testclient import TestClient +from app.core.directory_index_contract import CONTRACT_VERSION from app.models.connection import Connection, ConnectionScope from app.models.file import DirectoryListing, DirectorySearchResult, FileInfo, FileType -from app.services.directory_cache import CacheState, ConnectionDirectoryCache +from app.services.directory_cache import CacheState, ConnectionDirectoryCache, SQLiteConnectionDirectoryCache +from app.services.directory_index import MAX_QUERY_BYTES, MAX_QUERY_TERMS + +WORKER_TIMEOUT_SECONDS = 5 +HTTP_MEASUREMENT_ROWS = 100_000 +HTTP_MEASUREMENT_SAMPLES = 30 # ============================================================================ # Fixtures @@ -21,17 +35,20 @@ @pytest.fixture(name="mock_cache") -def mock_cache_fixture() -> ConnectionDirectoryCache: +def mock_cache_fixture(tmp_path, monkeypatch): """Create a pre-populated mock-friendly cache for testing.""" - cache = ConnectionDirectoryCache( + monkeypatch.setattr("app.services.directory_cache.settings.directory_cache_location", str(tmp_path / "cache")) + cache = SQLiteConnectionDirectoryCache( connection_id="test", host="host", share_name="share", username="user", password="pass", ) - cache.add_directories( + cache.index.open() + cache._opened.set() + cache.index.write_paths( [ "documents", "documents/work", @@ -40,8 +57,12 @@ def mock_cache_fixture() -> ConnectionDirectoryCache: "photos/vacation", ] ) + cache.index.finish_generation(1) + cache._directory_count = cache.index.status().directory_count + cache._source_available.set() cache._state = CacheState.READY - return cache + yield cache + cache.stop() @pytest.fixture(name="mock_cache_manager") @@ -61,10 +82,201 @@ def mock_cache_manager_fixture(mock_cache: ConnectionDirectoryCache): # ============================================================================ +@pytest.mark.skipif( + os.environ.get("DIRECTORY_INDEX_HTTP_MEASUREMENT") != "1", + reason="Explicit HTTP latency measurement; not part of routine tests.", +) +def test_directory_search_http_latency_measurement(client, mock_cache_manager, auth_headers_user, test_connection, mock_cache): + """Measure the real HTTP boundary separately from long source scans.""" + import json + + count = int(os.environ.get("DIRECTORY_INDEX_HTTP_MEASURE_COUNT", str(HTTP_MEASUREMENT_ROWS))) + assert count > 0 + index = mock_cache.index + generation = index.begin_verification() + index.write_paths((f"directory-{number:06d}" for number in range(count)), generation_id=generation) + assert index.finish_generation(generation) + mock_cache._directory_count = count + query = f"{count - 1:06d}" + samples = [] + for _ in range(HTTP_MEASUREMENT_SAMPLES): + started = time.perf_counter() + response = client.get(f"/api/browse/{test_connection.id}/directories", headers=auth_headers_user, params={"q": query}) + samples.append((time.perf_counter() - started) * 1000) + assert response.status_code == 200, response.json() + data = response.json() + assert data["directory_count"] == count + assert data["total_matches"] == 1 + assert data["results"] == [f"directory-{count - 1:06d}"] + print( + "DIRECTORY_INDEX_HTTP_MEASUREMENT " + + json.dumps( + { + "rows": count, + "samples": len(samples), + "p50_ms": statistics.median(samples), + "p95_ms": sorted(samples)[ceil(len(samples) * 95 / 100) - 1], + "max_ms": max(samples), + "cache_target_kib_per_connection": mock_cache.policy.page_cache_kib, + "method": "FastAPI TestClient with authentication and seeded SQLite; excludes source build time", + }, + sort_keys=True, + ), + flush=True, + ) + + @pytest.mark.integration class TestSearchDirectories: """Tests for GET /{connection_id}/directories endpoint.""" + def test_legacy_search_does_not_advertise_index_contract(self, client, auth_headers_user, test_connection, mock_cache_manager): + """Only the SQLite implementation can opt in to compatible quiet refresh.""" + cache = MagicMock(spec=ConnectionDirectoryCache) + cache.search.return_value = ([], 0) + cache.state = CacheState.READY + cache.directory_count = 0 + mock_cache_manager.get_or_create_cache.return_value = cache + response = client.get(f"/api/browse/{test_connection.id}/directories", headers=auth_headers_user, params={"q": "photos"}) + assert response.status_code == 200 + assert response.json()["search_contract_version"] is None + + @pytest.mark.parametrize( + "query,expected_status", + [ + ("a" * MAX_QUERY_BYTES, 200), + ("\U0001f600" * (MAX_QUERY_BYTES // 4), 200), + ("\U0001f600" * (MAX_QUERY_BYTES // 4 + 1), 400), + (" ".join(f"term{number}" for number in range(MAX_QUERY_TERMS)), 200), + (" ".join(f"term{number}" for number in range(MAX_QUERY_TERMS + 1)), 400), + ], + ids=["maximum-ascii-bytes", "maximum-utf8-bytes", "excess-utf8-bytes", "maximum-terms", "excess-terms"], + ) + def test_search_uses_shared_input_limits(self, client, auth_headers_user, test_connection, mock_cache_manager, query, expected_status): + """HTTP validation accepts the shared byte range and rejects excess bytes or terms.""" + response = client.get( + f"/api/browse/{test_connection.id}/directories", + headers=auth_headers_user, + params={"q": query}, + ) + assert response.status_code == expected_status + if expected_status == 200: + assert response.json()["results"] == [] + assert response.json()["directory_count"] == 5 + else: + assert "Shorten" in response.json()["detail"] or "Remove some terms" in response.json()["detail"] + + def test_search_preserves_exact_metadata_when_output_is_truncated( + self, client, auth_headers_user, test_connection, mock_cache_manager, mock_cache, monkeypatch + ): + """The HTTP serializer must not cap exact counts or lose selected-generation metadata.""" + selected = replace(mock_cache.index.search("photos"), directory_count=100_001, total_matches=100_001) + monkeypatch.setattr(mock_cache, "search_index", lambda *_args, **_kwargs: selected) + response = client.get(f"/api/browse/{test_connection.id}/directories", headers=auth_headers_user, params={"q": "photos"}) + assert response.status_code == 200 + data = response.json() + assert data["directory_count"] == data["total_matches"] == 100_001 + assert data["search_contract_version"] == CONTRACT_VERSION + assert len(data["results"]) == len(selected.results) < data["total_matches"] + assert data["accepted_revision"] == selected.accepted_revision + assert data["coverage"] == selected.coverage + assert data["last_verified_at_ms"] == selected.last_verified_at_ms + assert data["matched_term_counts"] == {row.path: row.matched_term_count for row in selected.results} + + def test_search_retains_accepted_rows_and_reports_uncertain_paused_status( + self, client, auth_headers_user, test_connection, mock_cache_manager, mock_cache + ): + """Uncertainty and storage refusal are not empty or newly verified catalogues.""" + selected = mock_cache.index.search("photos") + mock_cache.index.mark_uncertain() + mock_cache._scan_error = "Directory indexing is paused. Increase its disk budget and retry." + response = client.get(f"/api/browse/{test_connection.id}/directories", headers=auth_headers_user, params={"q": "photos"}) + assert response.status_code == 200 + data = response.json() + assert data["results"] == [row.path for row in selected.results] + assert data["coverage"] == "uncertain" + assert data["coverage_issue_count"] == 1 + assert data["status_message"] == mock_cache.scan_error + assert data["last_verified_at_ms"] == selected.last_verified_at_ms + + @pytest.mark.parametrize("stopped", [False, True], ids=["root-unavailable", "binding-stopped"]) + def test_search_hides_retained_rows_after_source_loss( + self, client, auth_headers_user, test_connection, mock_cache_manager, mock_cache, stopped + ): + """Source loss returns a retryable failure without publishing or deleting retained rows.""" + selected = mock_cache.index.search("photos") + if stopped: + mock_cache._stop_event.set() + else: + mock_cache._source_probe_completed.set() + mock_cache._source_available.clear() + response = client.get(f"/api/browse/{test_connection.id}/directories", headers=auth_headers_user, params={"q": "photos"}) + assert response.status_code == 503 + detail = response.json()["detail"] + assert "retry" in detail["message"].lower() + assert detail["code"] == "directory_temporarily_unavailable" + assert detail["recoverable"] is True + assert detail["retry_after_ms"] == 5000 + assert response.headers["Retry-After"] == "5" + assert "results" not in response.json() + assert mock_cache.index.search("photos") == selected + + def test_search_storage_error_does_not_expose_private_details( + self, client, auth_headers_user, test_connection, mock_cache_manager, mock_cache, monkeypatch + ): + """A failed storage read has no successful result or private error text.""" + private_detail = "private directory, account, and storage identity" + + def failed(*_args, **_kwargs): + raise sqlite3.DatabaseError(private_detail) + + monkeypatch.setattr(mock_cache.index, "search", failed) + response = client.get(f"/api/browse/{test_connection.id}/directories", headers=auth_headers_user, params={"q": "photos"}) + assert response.status_code == 500 + assert private_detail not in response.text + assert "results" not in response.json() + assert "local index storage" in response.json()["detail"] + + @pytest.mark.asyncio + async def test_cancelled_http_query_signals_the_retained_worker( + self, session, regular_user, test_connection, mock_cache_manager, mock_cache, monkeypatch + ): + """Cancellation reaches the actual worker and cannot return a late success.""" + from app.api.browser import search_directories + + started = threading.Event() + release = threading.Event() + exited = threading.Event() + tokens = [] + successful = [] + original = mock_cache.search_index + + def blocked(*args, cancelled, **kwargs): + tokens.append(cancelled) + started.set() + try: + if not release.wait(WORKER_TIMEOUT_SECONDS): + raise TimeoutError("Test query worker was not released.") + response = original(*args, cancelled=cancelled, **kwargs) + successful.append(response) + return response + finally: + exited.set() + + monkeypatch.setattr(mock_cache, "search_index", blocked) + task = asyncio.create_task(search_directories(test_connection.id, "photos", False, current_user=regular_user, session=session)) + try: + assert await asyncio.to_thread(started.wait, WORKER_TIMEOUT_SECONDS) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert tokens[0].is_set() + assert not exited.is_set() + finally: + release.set() + assert await asyncio.to_thread(exited.wait, WORKER_TIMEOUT_SECONDS) + assert not successful + def test_search_requires_auth( self, client: TestClient, @@ -143,6 +355,7 @@ def test_search_with_query( assert "total_matches" in data assert "cache_state" in data assert "directory_count" in data + assert data["search_contract_version"] == CONTRACT_VERSION # The mock_cache has 3 directories containing "documents" assert data["total_matches"] > 0 @@ -169,6 +382,7 @@ def test_search_returns_server_match_ranks( ): """Expose the cache's Unicode-aware ranks for cross-source merging.""" mock_cache.add_directories(paths) + mock_cache.coordinator.apply_pending() response = client.get( f"/api/browse/{test_connection.id}/directories", @@ -295,6 +509,7 @@ def test_search_excludes_dot_directories_by_default( "projects/visible-cache", ] ) + mock_cache.coordinator.apply_pending() response = client.get( f"/api/browse/{test_connection.id}/directories", @@ -324,6 +539,7 @@ def test_search_can_include_dot_directories( "projects/visible-cache", ] ) + mock_cache.coordinator.apply_pending() response = client.get( f"/api/browse/{test_connection.id}/directories", @@ -333,7 +549,7 @@ def test_search_can_include_dot_directories( assert response.status_code == 200 data = response.json() - assert data["results"] == ["projects/visible-cache", "projects/.cache"] + assert data["results"] == ["projects/.cache", "projects/visible-cache"] assert data["total_matches"] == 2 def test_search_triggers_cache_creation( @@ -474,13 +690,7 @@ def test_feeds_directories_to_cache(self): from app.api.browser import _update_directory_cache_from_listing - cache = ConnectionDirectoryCache( - connection_id="conn-1", - host="h", - share_name="s", - username="u", - password="p", - ) + cache = MagicMock(spec=ConnectionDirectoryCache) listing = DirectoryListing( path="/parent", @@ -520,7 +730,7 @@ def test_feeds_directories_to_cache(self): _update_directory_cache_from_listing("conn-1", listing) # Should have added the 2 directories (not the file) - assert cache.directory_count == 2 + cache.add_directories.assert_called_once_with(["/parent/subdir", "/parent/another_dir"]) def test_skips_when_no_cache_exists(self): """Should do nothing if no cache exists for connection.""" @@ -556,13 +766,7 @@ def test_skips_files_only_adds_directories(self): from app.api.browser import _update_directory_cache_from_listing - cache = ConnectionDirectoryCache( - connection_id="conn-1", - host="h", - share_name="s", - username="u", - password="p", - ) + cache = MagicMock(spec=ConnectionDirectoryCache) listing = DirectoryListing( path="/", @@ -594,7 +798,7 @@ def test_skips_files_only_adds_directories(self): ): _update_directory_cache_from_listing("conn-1", listing) - assert cache.directory_count == 0 + cache.add_directories.assert_not_called() def test_does_not_crash_on_exception(self): """Should silently handle any exceptions.""" @@ -615,13 +819,7 @@ def test_empty_listing_is_noop(self): from app.api.browser import _update_directory_cache_from_listing - cache = ConnectionDirectoryCache( - connection_id="conn-1", - host="h", - share_name="s", - username="u", - password="p", - ) + cache = MagicMock(spec=ConnectionDirectoryCache) listing = DirectoryListing(path="/", items=[], total=0) @@ -634,4 +832,4 @@ def test_empty_listing_is_noop(self): ): _update_directory_cache_from_listing("conn-1", listing) - assert cache.directory_count == 0 + cache.add_directories.assert_not_called() diff --git a/backend/tests/test_logging_config.py b/backend/tests/test_logging_config.py index 8b6d1de5a..66b895ea0 100644 --- a/backend/tests/test_logging_config.py +++ b/backend/tests/test_logging_config.py @@ -1,11 +1,83 @@ """Tests for backend logging configuration and frontend logging API.""" +import errno import io import logging +import sqlite3 +import sys +import pytest from fastapi.testclient import TestClient -from app.core.logging import SensitiveDataLogFilter, UvicornProtocolLogFilter, configure_uvicorn_loggers +from app.core.logging import RecoveryLogFormatter, SensitiveDataLogFilter, UvicornProtocolLogFilter, configure_uvicorn_loggers + + +def test_recovery_formatter_renders_safe_codes_from_causes_without_tracebacks(): + cause = sqlite3.OperationalError("private database path") + cause.sqlite_errorcode = sqlite3.SQLITE_BUSY + error = OSError(errno.EACCES, "private source path") + error.__cause__ = cause + record = logging.LogRecord("app.storage.smb", logging.WARNING, __file__, 1, "Recovery failed", (), (type(error), error, None)) + output = RecoveryLogFormatter("%(message)s").format(record) + assert f"errno={errno.EACCES}" in output + assert f"sqlite_errorcode={sqlite3.SQLITE_BUSY}" in output + assert "PermissionError" in output + assert "OperationalError" in output + assert "private" not in output + + +@pytest.mark.parametrize("name", ["smbclient._io", "smbprotocol.connection"]) +def test_protocol_diagnostics_omit_private_payload_and_exception_text(name): + try: + raise OSError("private host, path and credentials") + except OSError: + record = logging.LogRecord(name, logging.WARNING, __file__, 1, "private payload %s", ("private user",), sys.exc_info()) + output = RecoveryLogFormatter("%(name)s %(message)s").format(record) + assert name in output + assert "OSError" in output + assert "private host, path and credentials" not in output + assert "private payload private user" not in output + assert "private user" not in output + assert __file__ not in output + assert record.getMessage() == "private payload private user" + + +def test_recovery_formatter_handles_unhashable_and_cyclic_exceptions() -> None: + class UnhashableError(RuntimeError): + __hash__ = None + + try: + raise UnhashableError("private exception text") + except UnhashableError as error: + error.__cause__ = error + record = logging.LogRecord("app.storage.smb", logging.WARNING, __file__, 1, "SMB recovery failed", (), sys.exc_info()) + output = RecoveryLogFormatter("%(message)s").format(record) + assert output.count("UnhashableError") == 1 + assert "private exception text" not in output + assert __file__ not in output + + +def test_recovery_formatter_renders_fields_without_private_exception_text() -> None: + try: + try: + raise OSError("private-path password=secret-private-value") + except OSError as cause: + raise RuntimeError("private-wrapper") from cause + except RuntimeError: + record = logging.LogRecord("app.storage.smb", logging.WARNING, __file__, 1, "SMB recovery failed", (), sys.exc_info()) + record.operation = "read_file" + record.source_id = "opaque-source" + record.generation_id = "opaque-generation" + record.retained_worker_count = 2 + output = RecoveryLogFormatter("%(message)s").format(record) + assert "private-path" not in output + assert "secret-private-value" not in output + assert "private-wrapper" not in output + assert __file__ not in output + assert "OSError" in output + assert "RuntimeError" in output + assert "operation='read_file'" in output + assert "retained_worker_count=2" in output def test_sensitive_data_log_filter_redacts_access_credentials() -> None: diff --git a/backend/tests/test_recent_directories.py b/backend/tests/test_recent_directories.py index 8d73daba7..0a8af3415 100644 --- a/backend/tests/test_recent_directories.py +++ b/backend/tests/test_recent_directories.py @@ -158,6 +158,17 @@ async def get_file_info(self, path: str) -> FileInfo: class TestRecentDirectoryService: + def test_independent_terms_rank_before_frequency_and_before_limit(self, session: Session, regular_user: User) -> None: + for path, score in (("alpha", 1000), ("beta", 1000), ("alpha/beta", 1), ("none", 1000)): + session.add(RecentDirectory(user_id=regular_user.id, connection_id="local-drive:c", path=path, usage_score=score)) + session.commit() + for query in ("alpha beta", "beta alpha", "ALPHA alpha beta"): + results = recent_directories.search_recent_directories(query=query, limit=1, current_user=regular_user, session=session) + assert results[0].path == "alpha/beta" + assert results[0].matched_term_count == 2 + results = recent_directories.search_recent_directories(query="alpha beta", limit=50, current_user=regular_user, session=session) + assert {record.path for record in results} == {"alpha", "beta", "alpha/beta"} + def test_frequency_decay_and_duplicate_events(self) -> None: now = datetime.now(timezone.utc) assert history_usage_score(8, now - timedelta(days=30), now) == pytest.approx(4) diff --git a/backend/tests/test_recent_files.py b/backend/tests/test_recent_files.py index acb95adc6..9536bb0c8 100644 --- a/backend/tests/test_recent_files.py +++ b/backend/tests/test_recent_files.py @@ -17,6 +17,21 @@ class TestRecentFileService: + def test_independent_filename_terms_rank_before_frequency(self, session: Session, regular_user: User) -> None: + for name, score in (("alpha.txt", 1000), ("beta.txt", 1000), ("alpha-beta.txt", 1), ("none.txt", 1000)): + session.add( + RecentFile( + user_id=regular_user.id, connection_id="local-drive:c", path=f"parent/alpha/{name}", file_name=name, usage_score=score + ) + ) + session.commit() + for query in ("alpha beta", "beta alpha", "ALPHA alpha beta"): + results = recent_files.search_recent_files(query=query, limit=1, current_user=regular_user, session=session) + assert results[0].file_name == "alpha-beta.txt" + assert results[0].matched_term_count == 2 + results = recent_files.search_recent_files(query="alpha beta", limit=50, current_user=regular_user, session=session) + assert {record.file_name for record in results} == {"alpha.txt", "beta.txt", "alpha-beta.txt"} + def test_ranks_quality_before_decaying_frequency(self, session: Session, regular_user: User) -> None: now = datetime.now(timezone.utc) for name, score, age in (("report-old.txt", 8, 1), ("report-new.txt", 1, 0), ("report", 1, 2), ("report-stale.txt", 8, 120)): @@ -58,6 +73,14 @@ def test_counts_a_later_open_but_not_a_rapid_duplicate(self, session: Session, r class TestRecentFilesApi: + @pytest.mark.parametrize("endpoint", ["recent-files", "recent-directories"]) + def test_rejects_excess_terms_with_actionable_error(self, client: TestClient, auth_headers_user: dict[str, str], endpoint: str) -> None: + response = client.get( + f"/api/browse/{endpoint}", headers=auth_headers_user, params={"q": " ".join(f"term{number}" for number in range(33))} + ) + assert response.status_code == 400 + assert "distinct terms" in response.json()["detail"] + @pytest.fixture(autouse=True) def mock_remote_target(self, monkeypatch: MonkeyPatch) -> None: import app.api.browser as browser_api diff --git a/backend/tests/test_release_workflow_configuration.py b/backend/tests/test_release_workflow_configuration.py index 6ef037398..34cc2a5fc 100644 --- a/backend/tests/test_release_workflow_configuration.py +++ b/backend/tests/test_release_workflow_configuration.py @@ -1,11 +1,48 @@ +import importlib.util +import json import re +import subprocess from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, Mock +import pytest import yaml WORKSPACE = Path(__file__).parents[2] +@pytest.mark.parametrize("cleanup_error", [subprocess.TimeoutExpired("taskkill", 15), subprocess.CalledProcessError(1, "taskkill")]) +def test_companion_recovery_smoke_kills_process_when_taskkill_fails(tmp_path, monkeypatch, cleanup_error): + script = WORKSPACE / "companion/scripts/recovery-smoke.py" + spec = importlib.util.spec_from_file_location("recovery_smoke", script) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + options = SimpleNamespace(executable=tmp_path / "companion.exe", report_dir=tmp_path, source_sha="test-sha", run_id="test-run") + monkeypatch.setattr(module.argparse.ArgumentParser, "parse_args", lambda self: options) + monkeypatch.setattr(module, "os", SimpleNamespace(name="nt", environ={})) + monkeypatch.setattr(module, "socket", SimpleNamespace(socket=MagicMock())) + opener = Mock() + opener.open.side_effect = RuntimeError("Controlled startup failure") + monkeypatch.setattr(module.urllib.request, "build_opener", lambda *args: opener) + process = Mock() + process.poll.return_value = None + cleanup = Mock(side_effect=cleanup_error) + monkeypatch.setattr( + module, "subprocess", SimpleNamespace(Popen=Mock(return_value=process), run=cleanup, SubprocessError=subprocess.SubprocessError) + ) + + assert module.main() == 1 + process.kill.assert_called_once_with() + process.wait.assert_called_once_with(timeout=module.SHUTDOWN_TIMEOUT_SECONDS) + assert cleanup.call_args.kwargs["check"] is True + report = json.loads((tmp_path / "recovery.json").read_text(encoding="utf-8")) + assert report["passed"] is False + assert report["source_sha"] == options.source_sha + assert report["run_id"] == options.run_id + + def load_workflow(name: str) -> dict: with (WORKSPACE / ".github/workflows" / name).open(encoding="utf-8") as file: payload = yaml.safe_load(file) @@ -215,6 +252,27 @@ def test_companion_verification_packages_are_never_signed() -> None: assert any(step.get("name") == "Install trusted-signing-cli" for step in release_steps) +def test_companion_recovery_verification_builds_before_smoke_and_keeps_evidence() -> None: + workflow = load_workflow("verify-companion-build.yml") + assert set(workflow["jobs"]) == {"change-scope", "windows-x64"} + job = workflow["jobs"]["windows-x64"] + assert job["timeout-minutes"] <= 45 + steps = job["steps"] + build = next(index for index, step in enumerate(steps) if step.get("name") == "Build Windows x64 package") + smoke = next(index for index, step in enumerate(steps) if step.get("name") == "Run packaged-process recovery smoke test") + assert build < smoke + assert "--features recovery-verification" in steps[build]["run"] + assert "--source-sha '${{ github.sha }}' --run-id '${{ github.run_id }}'" in steps[smoke]["run"] + assert steps[smoke]["timeout-minutes"] <= 5 + artifact = next(step for step in steps if step.get("uses", "").startswith("actions/upload-artifact@")) + assert artifact["if"] == "${{ always() }}" + assert "${{ github.sha }}" in artifact["with"]["name"] + assert "recovery-report/" in artifact["with"]["path"] + assert not any( + "recovery-verification" in step.get("run", "") for step in load_workflow("build-companion.yml")["jobs"]["build"]["steps"] + ) + + def test_workflow_output_references_use_direct_dependencies() -> None: dependency_pattern = re.compile(r"needs\.([A-Za-z0-9_-]+)\.outputs") workflow_directory = WORKSPACE / ".github/workflows" diff --git a/backend/tests/test_smb_backend.py b/backend/tests/test_smb_backend.py index 48c86db85..137bacf9d 100644 --- a/backend/tests/test_smb_backend.py +++ b/backend/tests/test_smb_backend.py @@ -27,6 +27,7 @@ import pytest import smbclient +import smbclient.path from smbclient._os import FileAttributes from smbprotocol.exceptions import AccessDenied, SMBOSError from smbprotocol.file_info import FileDispositionInformation, InfoType @@ -41,6 +42,7 @@ from app.services.content_transfer import SourceDeleteError, SourceDeletionOutcomeUnknown from app.services.cross_connection import cross_connection_move from app.storage.smb import SMB_FILETIME_TICKS_PER_SECOND, SMBBackend +from app.storage.smb_errors import SMBMutationUncertain _LIVE_SMB_ENVIRONMENT_VARIABLES = ("TEST_SMB_HOST", "TEST_SMB_SHARE", "TEST_SMB_USERNAME", "TEST_SMB_PASSWORD") _LIVE_SMB_ENABLED = all(os.environ.get(name) for name in _LIVE_SMB_ENVIRONMENT_VARIABLES) @@ -99,6 +101,8 @@ async def mock_get_connection(host, port, username, password, share_name, connec mock_pool_instance = MagicMock() mock_pool_instance.get_connection = mock_get_connection mock_pool_instance.retain_connection_until_future_complete = AsyncMock() + mock_pool_instance.invalidate_connection = AsyncMock() + mock_pool_instance.generation_aborting.return_value = False mock_pool.return_value = mock_pool_instance yield mock_pool @@ -119,6 +123,75 @@ def open_target(_path, *, mode, **_kwargs): assert target.read_bytes() == b"draft" +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("method", "arguments", "affected"), + [ + ("create_directory", ("parent/new",), ("parent/new",)), + ("create_file", ("parent/new.txt",), ("parent/new.txt",)), + ("delete_item", ("parent/old",), ("parent/old",)), + ("remove_empty_directory", ("parent/empty",), ("parent/empty",)), + ("rename_item", ("parent/old", "new"), ("parent/old", "parent/new")), + ("copy_item", ("source/tree", "destination/tree"), ("destination/tree",)), + ("move_item", ("source/tree", "destination/tree"), ("source/tree", "destination/tree")), + ("write_file", ("parent/file.txt", io.BytesIO(b"data")), ("parent/file.txt",)), + ], +) +async def test_namespace_mutations_pass_explicit_affected_paths(method, arguments, affected, monkeypatch) -> None: + backend = SMBBackend(host="server", share_name="share", username="user", password="pass", path_prefix="/scoped") + observations = [] + + async def execute(name, operation, timeout, *, smb_path, mutation_paths=()): + if mutation_paths: + observations.append(mutation_paths) + return False + + monkeypatch.setattr(backend, "_run_blocking_smb_operation", execute) + await getattr(backend, method)(*arguments) + assert observations == [tuple(backend._build_smb_path(path) for path in affected)] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failed", [False, True]) +async def test_recursive_copy_observes_child_destinations_through_actual_exit(monkeypatch, failed): + from types import SimpleNamespace + + backend = SMBBackend(host="server", share_name="share", username="user", password="pass") + source = backend._build_smb_path("source") + destination = backend._build_smb_path("destination") + child_destination = destination + "\\child" + observations = [] + created = [] + + def observe(paths): + if paths: + observations.append(paths) + + def scan(path, **kwargs): + if path == source: + yield SimpleNamespace(name="child", path=source + "\\child") + + def create(path, **kwargs): + assert observations[-1] == (path,) + created.append(path) + if failed and path == child_destination: + raise PermissionError("Injected partial copy failure.") + + monkeypatch.setattr(backend, "_observe_mutation_paths", observe) + monkeypatch.setattr(smbclient.path, "exists", lambda *args, **kwargs: False) + monkeypatch.setattr(smbclient, "stat", lambda *args, **kwargs: SimpleNamespace(st_mode=stat_module.S_IFDIR, st_atime=0, st_mtime=0)) + monkeypatch.setattr(smbclient, "scandir", scan) + monkeypatch.setattr(smbclient, "mkdir", create) + monkeypatch.setattr(smbclient, "utime", lambda *args, **kwargs: None) + if failed: + with pytest.raises(PermissionError, match="partial copy failure"): + await backend.copy_item("source", "destination") + else: + await backend.copy_item("source", "destination") + assert created == [destination, child_destination] + assert observations == [(destination,), (child_destination,), (child_destination,), (destination,)] + + class _MemoryArchiveReader: def __init__(self, data: bytes) -> None: self._data = data @@ -1228,6 +1301,93 @@ async def test_read_file_closes_on_error(self, mock_open): class TestRandomAccessReading: """Test operation-scoped SMB readers used by archive operations.""" + @pytest.mark.asyncio + @pytest.mark.parametrize("cancelled", [False, True]) + @pytest.mark.parametrize("late_failure", [False, True]) + async def test_abandoned_read_keeps_handle_until_worker_exits(self, cancelled, late_failure): + started = Event() + finish = Event() + released = asyncio.Event() + handle = MagicMock(spec=["close", "seek", "read"]) + + @asynccontextmanager + async def lease(**_kwargs): + try: + yield + finally: + released.set() + + def read(_length): + started.set() + assert finish.wait(2) + if late_failure: + raise OSError("read failed after caller left") + return b"data" + + handle.read.side_effect = read + pool = MagicMock() + pool.get_connection = lease + pool.invalidate_connection = AsyncMock() + backend = SMBBackend(host="server.local", share_name="share", username="user", password="pass") + with ( + patch("app.storage.smb.get_connection_pool", new_callable=AsyncMock, return_value=pool), + patch("app.storage.smb.smbclient.open_file", return_value=handle), + patch("app.storage.smb.SMB_READ_CHUNK_TIMEOUT_SECONDS", 2 if cancelled else 0.01), + patch("app.storage.smb.SMB_FILE_CLOSE_TIMEOUT_SECONDS", 0.01), + ): + reader = await backend.open_random_access_reader("backup.zip") + try: + reading = asyncio.create_task(reader.read_at(0, 4)) + assert await asyncio.to_thread(started.wait, 2) + if cancelled: + reading.cancel() + with pytest.raises(asyncio.CancelledError if cancelled else TimeoutError): + await reading + with pytest.raises(ValueError, match="closed"): + await reader.read_at(0, 4) + with pytest.raises(TimeoutError): + await reader.close() + handle.close.assert_not_called() + assert not released.is_set() + finally: + finish.set() + await asyncio.wait_for(released.wait(), 2) + await reader.close() + handle.close.assert_called_once() + assert pool.invalidate_connection.await_count == (0 if cancelled else 1) + + @pytest.mark.asyncio + @pytest.mark.parametrize("reader_method", ["open_random_access_reader", "open_move_source_reader"]) + async def test_close_failure_releases_lease_and_reports_once(self, reader_method): + released = asyncio.Event() + handle = MagicMock(spec=["close", "seek", "read", "fd"]) + handle.close.side_effect = OSError("private close failure") + + @asynccontextmanager + async def lease(**_kwargs): + try: + yield + finally: + released.set() + + pool = MagicMock() + pool.get_connection = lease + backend = SMBBackend(host="server.local", share_name="share", username="user", password="pass") + with ( + patch("app.storage.smb.get_connection_pool", new_callable=AsyncMock, return_value=pool), + patch("app.storage.smb.smbclient.open_file", return_value=handle), + patch("app.storage.smb.logger.warning") as warning, + ): + reader = await getattr(backend, reader_method)("private-source") + for _attempt in range(2): + with pytest.raises(OSError, match="private close failure"): + await reader.close() + assert released.is_set() + handle.close.assert_called_once() + failures = [call for call in warning.call_args_list if call.kwargs.get("extra", {}).get("event") == "smb_cleanup_failed"] + assert len(failures) == 1 + assert "private" not in failures[0].args[0] + @pytest.mark.asyncio @patch("app.storage.smb.smbclient.open_file") async def test_random_reader_reads_at_offset_and_closes(self, mock_open): @@ -1361,11 +1521,15 @@ async def test_move_pre_dispatch_failure_definitely_retains_source(self, closed) @pytest.mark.asyncio @pytest.mark.parametrize("cancelled", [False, True]) - async def test_timed_out_move_open_disposes_late_handle_and_releases_lease(self, cancelled): + @pytest.mark.parametrize("reader_method", ["open_move_source_reader", "open_random_access_reader"]) + @pytest.mark.parametrize("late_failure", [None, "open", "close"]) + async def test_timed_out_move_open_disposes_late_handle_and_releases_lease(self, cancelled, reader_method, late_failure): started = Event() finish = Event() released = asyncio.Event() handle = MagicMock(spec=["close", "fd", "seek", "read"]) + if late_failure == "close": + handle.close.side_effect = OSError("private close failure") @asynccontextmanager async def lease(**_kwargs): @@ -1377,33 +1541,43 @@ async def lease(**_kwargs): def open_handle(*_args, **_kwargs): started.set() assert finish.wait(2) + if late_failure == "open": + raise OSError("private open failure") return handle pool = MagicMock() pool.get_connection = lease + pool.invalidate_connection = AsyncMock() backend = SMBBackend(host="server.local", share_name="share", username="user", password="pass") with ( patch("app.storage.smb.get_connection_pool", new_callable=AsyncMock, return_value=pool), patch("app.storage.smb.smbclient.open_file", side_effect=open_handle), patch("app.storage.smb.SMB_FILE_OPEN_TIMEOUT_SECONDS", 2 if cancelled else 0.01), + patch("app.storage.smb.logger.warning") as warning, ): try: if cancelled: - opening = asyncio.create_task(backend.open_move_source_reader("source.txt")) + opening = asyncio.create_task(getattr(backend, reader_method)("source.txt")) assert await asyncio.to_thread(started.wait, 2) opening.cancel() with pytest.raises(asyncio.CancelledError): await opening else: with pytest.raises(TimeoutError): - await backend.open_move_source_reader("source.txt") + await getattr(backend, reader_method)("source.txt") assert started.is_set() assert not released.is_set() handle.close.assert_not_called() + assert pool.invalidate_connection.await_count == (0 if cancelled else 1) + if not cancelled: + assert pool.invalidate_connection.call_args.kwargs["abort"] is True finally: finish.set() await asyncio.wait_for(released.wait(), 2) - handle.close.assert_called_once() + await asyncio.sleep(0) + assert handle.close.call_count == (0 if late_failure == "open" else 1) + failures = [call for call in warning.call_args_list if call.kwargs.get("extra", {}).get("event") == "smb_cleanup_failed"] + assert len(failures) == (0 if late_failure is None else 1) @pytest.mark.asyncio async def test_timed_out_delete_closes_only_after_dispatch_settles(self): @@ -2181,7 +2355,7 @@ async def test_delete_directory_recursive(self, mock_stat, mock_scandir, mock_rm @pytest.mark.asyncio async def test_delete_timeout_raises(self): - """Test that a slow delete operation raises TimeoutError without starting a worker.""" + """A delete deadline leaves the dispatched change uncertain.""" backend = SMBBackend( host="server.local", share_name="share", @@ -2192,10 +2366,10 @@ async def test_delete_timeout_raises(self): with ( patch("app.storage.smb.asyncio.get_running_loop") as mock_get_running_loop, - patch("app.storage.smb.asyncio.wait_for", side_effect=asyncio.TimeoutError()), + patch("app.storage.smb.wait_for_worker", side_effect=asyncio.TimeoutError()), ): mock_get_running_loop.return_value.run_in_executor.return_value = pending_operation - with pytest.raises(TimeoutError, match="timed out"): + with pytest.raises(SMBMutationUncertain, match="Check the source before retrying"): await backend.delete_item("/big-file.zip") mock_get_running_loop.return_value.run_in_executor.assert_called_once() @@ -2220,6 +2394,7 @@ def delayed_failure() -> None: operation_name="test operation", smb_path=r"\\server.local\share\file.txt", timeout_seconds=0.001, + diagnostics={"source_id": "opaque-source", "generation_id": "retired-generation", "retained_worker_count": 1}, ) assert started.wait(timeout=1.0) @@ -2228,10 +2403,16 @@ def delayed_failure() -> None: await operation_future await asyncio.sleep(0) - mock_warning.assert_any_call( - "SMB %s for '%s' failed after timing out", - "test operation", - r"\\server.local\share\file.txt", + mock_warning.assert_called_once_with( + "SMB operation failed after caller exit.", + extra={ + "source_id": "opaque-source", + "generation_id": "retired-generation", + "retained_worker_count": 1, + "operation": "test_operation", + "stage": "late_completion", + "event": "smb_operation_late_failure", + }, exc_info=True, ) @@ -2357,7 +2538,7 @@ async def test_rename_collision_raises(self, mock_rename): @pytest.mark.asyncio @patch("app.storage.smb.smbclient.rename") async def test_rename_timeout_raises(self, mock_rename): - """Test that a slow rename operation raises TimeoutError.""" + """A rename deadline must not invite automatic replay.""" import asyncio backend = SMBBackend( @@ -2367,8 +2548,8 @@ async def test_rename_timeout_raises(self, mock_rename): password="pass", ) - with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError()): - with pytest.raises(TimeoutError, match="timed out"): + with patch("app.storage.smb.wait_for_worker", side_effect=asyncio.TimeoutError()): + with pytest.raises(SMBMutationUncertain, match="Check the source before retrying"): await backend.rename_item("/document.txt", "renamed.txt") @pytest.mark.asyncio diff --git a/backend/tests/test_smb_pool.py b/backend/tests/test_smb_pool.py index 53ab543ef..5e2c26599 100644 --- a/backend/tests/test_smb_pool.py +++ b/backend/tests/test_smb_pool.py @@ -5,18 +5,455 @@ """ import asyncio +import socket +import subprocess +import sys +import textwrap import uuid from datetime import datetime, timedelta, timezone -from unittest.mock import patch +from threading import Event +from unittest.mock import Mock, patch import pytest +from smbprotocol.connection import Connection as ProtocolConnection +from smbprotocol.transport import Tcp -from app.api._smb_helpers import build_smb_backend +from app.api._smb_helpers import build_smb_backend, file_target_http_error from app.models.connection import Connection from app.storage.smb import SMBBackend +from app.storage.smb_errors import SMBConnectionCapacityError, SMBConnectionRetired, SMBMutationUncertain, SMBOperationTimeout from app.storage.smb_pool import SMBConnectionPool, get_connection_pool, get_smb_connection_cache +@pytest.mark.asyncio +@pytest.mark.parametrize("reader_result", [False, True]) +async def test_installed_transport_failure_and_restoration_through_backend_pool(reader_result): + """Use isolated loopback transports, not a live SMB server or network changes.""" + pool = SMBConnectionPool() + source_key = str(uuid.uuid4()) + backend = SMBBackend(host="127.0.0.1", share_name="share", username="user", password="pass", connection_context_key=source_key) + first_cache = backend._connection_cache + peers = [] + started = Event() + stale_started = Event() + release_stale = Event() + operations = [] + reader = None + + def stale_result(*_args): + stale_started.set() + assert release_stale.wait(3) + return b"stale" + + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + listener.listen(2) + listener.settimeout(3) + port = listener.getsockname()[1] + + def register(_host, *, connection_cache, **_kwargs): + connection = ProtocolConnection(uuid.uuid4(), "127.0.0.1", port=port) + connection.transport = Tcp("127.0.0.1", port, timeout=3) + connection.transport.connect() + peer, _address = listener.accept() + peers.append(peer) + connection_cache["transport"] = connection + if connection_cache is not first_cache: + peer.sendall(b"\x00\x00\x00\x01x") + + def scan(_path, *, connection_cache, **_kwargs): + operations.append(connection_cache) + started.set() + packet = connection_cache["transport"].transport.recv(3) + entry = Mock() + entry.name = "recovered" if packet else "stale" + entry.smb_info.file_attributes = 16 + entry.smb_info.end_of_file = 0 + entry.smb_info.last_write_time = datetime.now(timezone.utc) + entry.smb_info.creation_time = datetime.now(timezone.utc) + return [entry] + + with ( + patch("smbclient.register_session", side_effect=register), + patch("smbclient.scandir", side_effect=scan), + patch("app.storage.smb.get_connection_pool", return_value=pool), + patch("app.storage.smb.SMB_LIST_DIRECTORY_TIMEOUT_SECONDS", 0.02), + ): + try: + async with pool.get_connection("127.0.0.1", 445, "user", "pass", "share", connection_cache=first_cache): + if reader_result: + handle = Mock(read=Mock(side_effect=stale_result)) + with patch("smbclient.open_file", return_value=handle): + reader = await backend.open_random_access_reader("source") + stale = asyncio.create_task(reader.read_at(0, 5)) + else: + stale = asyncio.create_task( + backend._run_blocking_smb_call("read_old_generation", stale_result, 3, smb_path="source") + ) + assert await asyncio.to_thread(stale_started.wait, 2) + with pytest.raises(SMBOperationTimeout): + await backend.list_directory("") + assert started.is_set() + with pytest.raises(SMBConnectionRetired): + async with pool.get_connection("127.0.0.1", 445, "user", "pass", "share", connection_cache=first_cache): + pytest.fail("Old generation must reject admission") + replacement = SMBBackend( + host="127.0.0.1", share_name="share", username="user", password="pass", connection_context_key=source_key + ) + assert replacement._connection_cache is not first_cache + async with pool.get_connection( + "127.0.0.1", 445, "user", "pass", "share", connection_cache=replacement._connection_cache + ): + listing = await replacement.list_directory("") + assert [item.name for item in listing.items] == ["recovered"] + release_stale.set() + with pytest.raises(SMBConnectionRetired): + await stale + assert operations == [first_cache, replacement._connection_cache] + await pool.invalidate_connection_cache(replacement._connection_cache) + third = get_smb_connection_cache(source_key) + with pytest.raises(SMBConnectionCapacityError): + async with pool.get_connection("127.0.0.1", 445, "user", "pass", "share", connection_cache=third): + pytest.fail("Two retained generations must prevent a third admission") + assert pool.generation_aborting(first_cache) + finally: + release_stale.set() + if reader is not None: + await reader.close() + for peer in peers: + peer.close() + await pool.close_all() + async with asyncio.timeout(5): + while pool._background_tasks or pool.get_stats()["total_references"]: + await asyncio.sleep(0) + if pool._background_tasks: + await asyncio.gather(*tuple(pool._background_tasks), return_exceptions=True) + assert pool.get_stats()["total_connections"] == 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("shared_abort", [False, True]) +async def test_mutation_deadline_is_uncertain_and_never_replayed(shared_abort): + pool = SMBConnectionPool() + backend = SMBBackend(host="server", share_name="share", username="user", password="pass") + finish = Event() + started = Event() + + def mutate(): + started.set() + assert finish.wait(3) + if shared_abort: + raise ConnectionError("Controlled shared transport abort") + + operation = Mock(side_effect=mutate) + with ( + patch("smbclient.register_session"), + patch("smbclient.reset_connection_cache"), + patch("app.storage.smb.get_connection_pool", return_value=pool), + ): + try: + async with pool.get_connection("server", 445, "user", "pass", "share", connection_cache=backend._connection_cache): + with pytest.raises(SMBMutationUncertain) as failure: + if shared_abort: + mutation = asyncio.create_task( + backend._run_blocking_smb_call("write_file", operation, 2, smb_path="target", mutation_paths=("target",)) + ) + assert await asyncio.to_thread(started.wait, 2) + await pool.invalidate_connection_cache(backend._connection_cache, abort=True) + finish.set() + await mutation + else: + await backend._run_blocking_smb_call("write_file", operation, 0.01, smb_path="target", mutation_paths=("target",)) + assert started.is_set() + response = file_target_http_error(failure.value) + assert response.status_code == 409 + assert response.detail["mutation_uncertain"] is True + assert response.detail["recoverable"] is False + operation.assert_called_once() + assert pool.get_stats()["active_connections"] == 1 + finally: + finish.set() + async with asyncio.timeout(5): + while pool._background_tasks or pool.get_stats()["total_references"]: + await asyncio.sleep(0) + if pool._background_tasks: + await asyncio.gather(*tuple(pool._background_tasks), return_exceptions=True) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("abort_fails", [False, True]) +async def test_timeout_abort_is_coalesced_retained_and_replacement_isolated(abort_fails): + pool = SMBConnectionPool() + started = Event() + finish = Event() + source_key = str(uuid.uuid4()) + cache = get_smb_connection_cache(source_key) + + def disconnect(*, close): + assert close is False + started.set() + assert finish.wait(3) + if abort_fails: + raise OSError("Controlled abort failure") + + transport = Mock(disconnect=Mock(side_effect=disconnect)) + replacement_transport = Mock() + arguments = dict(host="server", port=445, username="user", password="pass", share_name="share") + with patch("smbclient.register_session"), patch("smbclient.reset_connection_cache") as reset: + try: + async with pool.get_connection(**arguments, connection_cache=cache): + cache["server"] = transport + await pool.invalidate_connection_cache(cache, abort=True) + await pool.invalidate_connection_cache(cache, abort=True) + assert await asyncio.to_thread(started.wait, 2) + replacement = get_smb_connection_cache(source_key) + async with pool.get_connection(**arguments, connection_cache=replacement): + replacement["server"] = replacement_transport + assert pool.get_stats()["total_connections"] == 2 + reset.assert_not_called() + replacement_transport.disconnect.assert_not_called() + transport.disconnect.assert_called_once_with(close=False) + finally: + finish.set() + while pool._background_tasks: + await asyncio.gather(*tuple(pool._background_tasks), return_exceptions=True) + reset.assert_called_once_with(fail_on_error=False, connection_cache=cache) + + +@pytest.mark.parametrize("mode", ["receive", "send"]) +def test_installed_public_disconnect_interruptibility(mode): + """Isolate socket probes so a library regression cannot retain pytest threads.""" + probe = textwrap.dedent( + """ + import socket + import sys + import threading + import uuid + from smbprotocol.connection import Connection + from smbprotocol.exceptions import SMBConnectionClosed + from smbprotocol.transport import Tcp + + mode = sys.argv[1] + deadline = 5 + listener = socket.socket() + listener.bind(('127.0.0.1', 0)) + listener.listen(1) + listener.settimeout(deadline) + port = listener.getsockname()[1] + connection = Connection(uuid.uuid4(), '127.0.0.1', port=port) + if mode == 'send': + connection.transport = Tcp('127.0.0.1', port, timeout=deadline) + connection.transport.connect() + entered = threading.Event() + failures = [] + + def operation(): + entered.set() + try: + if mode == 'receive': + connection.connect(timeout=deadline) + else: + connection.transport.send(b'x' * Tcp.MAX_SIZE) + except BaseException as error: + failures.append(error) + + worker = threading.Thread(target=operation, daemon=True) + abort = threading.Thread(target=lambda: connection.disconnect(close=False), daemon=True) + peer = None + try: + worker.start() + peer, _ = listener.accept() + peer.settimeout(deadline) + if mode == 'receive': + assert peer.recv(65536), 'negotiate must be sent before abort' + assert entered.wait(deadline) + worker.join(0.1) + assert worker.is_alive(), 'operation must be blocked before abort' + abort.start() + if mode == 'send': + abort.join(0.1) + assert abort.is_alive(), 'send lock must retain abort until send exits' + while peer.recv(65536): + pass + worker.join(deadline) + abort.join(deadline) + assert not worker.is_alive() + assert not abort.is_alive() + if mode == 'receive': + assert len(failures) == 1 and isinstance(failures[0], SMBConnectionClosed), failures + else: + assert not failures, failures + finally: + if peer is not None: + peer.close() + listener.close() + print(mode + ': public abort verified') + """ + ) + result = subprocess.run([sys.executable, "-c", probe, mode], capture_output=True, text=True, timeout=15, check=False) + assert result.returncode == 0, result.stdout + result.stderr + assert f"{mode}: public abort verified" in result.stdout + + +@pytest.mark.asyncio +async def test_blocked_registration_does_not_block_another_source(): + pool = SMBConnectionPool() + started = Event() + finish = Event() + + def register(host, **_kwargs): + if host == "blocked": + started.set() + assert finish.wait(3) + + async def acquire(host): + async with pool.get_connection(host=host, port=445, username="user", password="pass", share_name="share"): + pass + + with patch("smbclient.register_session", side_effect=register): + blocked = asyncio.create_task(acquire("blocked")) + try: + assert await asyncio.to_thread(started.wait, 2) + await asyncio.wait_for(acquire("available"), 1) + finally: + finish.set() + await blocked + + +@pytest.mark.asyncio +async def test_cancelled_setup_is_owned_until_registration_and_reset_finish(): + pool = SMBConnectionPool() + started = Event() + finish = Event() + reset_complete = asyncio.Event() + loop = asyncio.get_running_loop() + cache = get_smb_connection_cache(str(uuid.uuid4())) + + def register(*_args, **_kwargs): + started.set() + assert finish.wait(3) + + def reset(**_kwargs): + loop.call_soon_threadsafe(reset_complete.set) + + async def acquire(): + async with pool.get_connection( + host="blocked", port=445, username="user", password="pass", share_name="share", connection_cache=cache + ): + pytest.fail("Cancelled setup must not yield a connection") + + with patch("smbclient.register_session", side_effect=register), patch("smbclient.reset_connection_cache", side_effect=reset) as cleanup: + acquiring = asyncio.create_task(acquire()) + try: + assert await asyncio.to_thread(started.wait, 2) + acquiring.cancel() + with pytest.raises(asyncio.CancelledError): + await acquiring + assert pool.get_stats()["active_connections"] == 1 + cleanup.assert_not_called() + finally: + finish.set() + await asyncio.wait_for(reset_complete.wait(), 2) + for task in tuple(pool._background_tasks): + try: + await task + except Exception: + pass + assert pool.get_stats()["total_connections"] == 0 + cleanup.assert_called_once() + + +@pytest.mark.asyncio +async def test_replacement_generation_is_isolated_and_bounded(): + pool = SMBConnectionPool() + source_key = str(uuid.uuid4()) + first = get_smb_connection_cache(source_key) + arguments = dict(host="server", port=445, username="user", password="pass", share_name="share") + with patch("smbclient.register_session"), patch("smbclient.reset_connection_cache") as reset: + async with pool.get_connection(**arguments, connection_cache=first): + await pool.invalidate_connection_cache(first) + second = get_smb_connection_cache(source_key) + assert second is not first + async with pool.get_connection(**arguments, connection_cache=second): + await pool.invalidate_connection_cache(second) + third = get_smb_connection_cache(source_key) + with pytest.raises(SMBConnectionCapacityError): + async with pool.get_connection(**arguments, connection_cache=third): + pytest.fail("Only two retained generations may be admitted") + reset.assert_not_called() + assert reset.call_count == 1 + assert reset.call_count == 2 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("limit_name", ["MAX_CONNECTION_GENERATIONS", "MAX_CONNECTION_SETUPS"]) +@pytest.mark.parametrize("late_failure", [False, True]) +async def test_simultaneous_sources_remain_bounded_until_setup_and_cleanup_exit(limit_name, late_failure): + pool = SMBConnectionPool() + started = {host: Event() for host in ("first", "second")} + finish = Event() + + def register(host, **_kwargs): + started[host].set() + assert finish.wait(3) + if late_failure: + raise OSError("Setup failed after cancellation") + + async def acquire(host): + async with pool.get_connection(host, 445, "user", "pass", "share"): + pytest.fail("Cancelled setup must not yield") + + with ( + patch(f"app.storage.smb_pool.{limit_name}", 2), + patch("smbclient.register_session", side_effect=register), + patch("smbclient.reset_connection_cache") as reset, + ): + tasks = [asyncio.create_task(acquire(host)) for host in started] + try: + for event in started.values(): + assert await asyncio.to_thread(event.wait, 2) + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + assert pool.get_stats()["total_connections"] == 2 + with pytest.raises(SMBConnectionCapacityError): + async with pool.get_connection("third", 445, "user", "pass", "share"): + pytest.fail("Retained setup must count against global capacity") + reset.assert_not_called() + finally: + finish.set() + await asyncio.gather(*tasks, return_exceptions=True) + while pool._background_tasks: + await asyncio.gather(*tuple(pool._background_tasks), return_exceptions=True) + assert pool.get_stats()["total_connections"] == 0 + assert reset.call_count == 2 + + +@pytest.mark.asyncio +async def test_global_admission_limit_rejects_work_without_queuing(): + pool = SMBConnectionPool() + with patch("app.storage.smb_pool.MAX_CONNECTION_ADMISSIONS", 3), patch("smbclient.register_session"): + async with pool.get_connection("first", 445, "user", "pass", "share"): + async with pool.get_connection("second", 445, "user", "pass", "share"): + with pytest.raises(SMBConnectionCapacityError): + async with pool.get_connection("third", 445, "user", "pass", "share"): + pytest.fail("Admission capacity must reject without queuing") + + +@pytest.mark.asyncio +async def test_shutdown_rejects_admission_without_resetting_active_work(): + pool = SMBConnectionPool() + with patch("smbclient.register_session"), patch("smbclient.reset_connection_cache") as reset: + async with pool.get_connection("first", 445, "user", "pass", "share"): + await pool.close_all() + reset.assert_not_called() + with pytest.raises(SMBConnectionRetired): + async with pool.get_connection("second", 445, "user", "pass", "share"): + pytest.fail("Shutdown must not admit new work") + reset.assert_called_once() + assert pool.get_stats()["total_connections"] == 0 + + @pytest.mark.asyncio async def test_connection_pool_reuses_connections(): """Test that connection pool reuses existing connections.""" diff --git a/backend/tests/test_system_settings.py b/backend/tests/test_system_settings.py index 1dfcbc438..3a107216b 100644 --- a/backend/tests/test_system_settings.py +++ b/backend/tests/test_system_settings.py @@ -16,7 +16,7 @@ from app.models.connection import Connection from app.models.oidc import OidcFlow, OidcFlowPurpose, OidcFlowStatus, OidcProviderConfiguration from app.models.system_settings import SmbAuthenticationModeUpdate, SmbPolicySettings, SystemSetting -from app.models.user import User +from app.models.user import User, UserRole from app.services.system_settings import ( SmbPolicyConfigurationError, get_integer_setting_value, @@ -43,6 +43,272 @@ def _create_system_settings_table(connection) -> None: ) +@pytest.mark.parametrize("scope", ["backend", "companion"]) +def test_directory_index_policy_settings_require_administrator(client, auth_headers_user, scope): + assert client.get(f"/api/admin/settings/directory-index/{scope}", headers=auth_headers_user).status_code == 403 + response = client.put( + f"/api/admin/settings/directory-index/{scope}", headers=auth_headers_user, json={"field": "page_cache_kib", "value": 1024} + ) + assert response.status_code == 403 + + +def test_directory_index_policy_updates_are_scoped_and_revisioned(client, auth_headers_admin): + response = client.put( + "/api/admin/settings/directory-index/backend", headers=auth_headers_admin, json={"field": "page_cache_kib", "value": 1024} + ) + assert response.status_code == 200 + assert response.json() == {"field": "page_cache_kib", "value": 1024} + backend = client.get("/api/admin/settings/directory-index/backend", headers=auth_headers_admin).json() + companion = client.get("/api/admin/settings/directory-index/companion", headers=auth_headers_admin).json() + assert backend["policy"]["page_cache_kib"] == 1024 + assert backend["revision"] == 1 + assert companion["policy"]["page_cache_kib"] == 2048 + assert companion["revision"] == 0 + assert client.put("/api/admin/settings/directory-index/backend", headers=auth_headers_admin, json=response.json()).status_code == 200 + assert client.get("/api/admin/settings/directory-index/backend", headers=auth_headers_admin).json()["revision"] == 1 + + +def test_directory_index_policy_correlated_update_rolls_back(client, auth_headers_admin): + response = client.put( + "/api/admin/settings/directory-index/backend", + headers=auth_headers_admin, + json={"field": "healthy_verification_seconds", "value": 60}, + ) + assert response.status_code == 400 + policy = client.get("/api/admin/settings/directory-index/backend", headers=auth_headers_admin).json() + assert policy["revision"] == 0 + assert policy["policy"]["healthy_verification_seconds"] == 900 + + +def test_directory_index_policy_rejects_extra_browser_values(client, auth_headers_admin): + response = client.put( + "/api/admin/settings/directory-index/companion", + headers=auth_headers_admin, + json={"field": "page_cache_kib", "value": 1024, "policy": {"disk_budget_bytes": 1}}, + ) + assert response.status_code == 422 + + +def _policy_grant_binding(): + import uuid + + return {"companion_id": str(uuid.uuid4()), "challenge": "challenge_" + "a" * 32, "origin": "https://sambee.example"} + + +@pytest.mark.parametrize("origin", ["https://" + ".".join(["\u00e9" * 20] * 50), "https://" + ".".join(["\u00e9"] * 256)]) +def test_directory_policy_origin_limits_input_and_canonical_utf8_bytes(origin): + from pydantic import ValidationError + + from app.models.system_settings import DirectoryPolicyGrantBinding + + with pytest.raises(ValidationError, match="2048 UTF-8 bytes"): + DirectoryPolicyGrantBinding(**{**_policy_grant_binding(), "origin": origin}) + + +def test_directory_policy_origin_accepts_exact_utf8_byte_limit(): + from app.core.directory_index_contract import MAX_DIRECTORY_POLICY_ORIGIN_BYTES + from app.models.system_settings import DirectoryPolicyGrantBinding + + prefix = "https://" + origin = prefix + "a" * (MAX_DIRECTORY_POLICY_ORIGIN_BYTES - len(prefix)) + binding = DirectoryPolicyGrantBinding(**{**_policy_grant_binding(), "origin": origin}) + assert len(binding.origin.encode("utf-8")) == MAX_DIRECTORY_POLICY_ORIGIN_BYTES + + +def test_directory_policy_grant_is_single_use_and_returns_backend_policy(client, auth_headers_admin): + binding = _policy_grant_binding() + issued = client.post( + "/api/admin/settings/directory-index/companion/grants", headers={**auth_headers_admin, "Origin": binding["origin"]}, json=binding + ) + assert issued.status_code == 200 + redeem = {**binding, "grant": issued.json()["grant"]} + desired = client.get("/api/admin/settings/directory-index/companion", headers=auth_headers_admin) + assert desired.status_code == 200 + assert desired.json()["backend_identity"] == issued.json()["backend_identity"] + accepted = client.post("/api/admin/settings/directory-index/companion/grants/redeem", json=redeem) + assert accepted.status_code == 200 + assert accepted.json()["policy"]["disk_budget_bytes"] == 2147483648 + assert accepted.json()["backend_identity"] == issued.json()["backend_identity"] + assert client.post("/api/admin/settings/directory-index/companion/grants/redeem", json=redeem).status_code == 409 + + +def test_directory_policy_grant_concurrent_redemption_has_one_winner(tmp_path, admin_user, monkeypatch): + from concurrent.futures import ThreadPoolExecutor + from threading import Barrier + + from fastapi import HTTPException + from sqlmodel import SQLModel + + from app.models.system_settings import DirectoryPolicyGrantBinding, DirectoryPolicyGrantRedeem + from app.services import directory_policy_grants + + engine = create_engine(f"sqlite:///{tmp_path / 'policy.sqlite3'}", connect_args={"check_same_thread": False}) + SQLModel.metadata.create_all(engine) + try: + binding = DirectoryPolicyGrantBinding(**_policy_grant_binding()) + with Session(engine) as session: + administrator = User.model_validate(admin_user.model_dump(exclude={"username_key"})) + session.add(administrator) + session.commit() + issued = directory_policy_grants.issue_directory_policy_grant(binding, administrator, session) + redeem = DirectoryPolicyGrantRedeem(**binding.model_dump(), grant=issued.grant) + ready = Barrier(2) + authorize = directory_policy_grants._administrator + + def concurrent_authorization(session, user_id): + administrator = authorize(session, user_id) + ready.wait(timeout=5) + return administrator + + monkeypatch.setattr(directory_policy_grants, "_administrator", concurrent_authorization) + + def consume(): + with Session(engine) as session: + try: + directory_policy_grants.redeem_directory_policy_grant(redeem, session) + except HTTPException as error: + return error.status_code + return 200 + + with ThreadPoolExecutor(max_workers=2) as workers: + results = list(workers.map(lambda _: consume(), range(2))) + assert sorted(results) == [200, 409] + finally: + engine.dispose() + + +def test_directory_policy_revisions_are_safe_wire_integers(): + from app.core.directory_index_contract import MAX_WIRE_INTEGER + from app.models.system_settings import DirectoryIndexPolicy, DirectoryIndexSettingsRead + + values = {"scope": "companion", "policy": DirectoryIndexPolicy(), "backend_identity": "a" * 64, "fields": {}} + assert DirectoryIndexSettingsRead(**values, revision=MAX_WIRE_INTEGER).revision == MAX_WIRE_INTEGER + with pytest.raises(ValueError): + DirectoryIndexSettingsRead(**values, revision=MAX_WIRE_INTEGER + 1) + + +@pytest.mark.parametrize("permission_change", ["role", "disabled", "expired", "token_version"]) +def test_directory_policy_grant_rechecks_current_administrator(client, auth_headers_admin, admin_user, session, permission_change): + from app.models.companion_uri_token_jti import CompanionDirectoryPolicyGrant + + binding = _policy_grant_binding() + issued = client.post( + "/api/admin/settings/directory-index/companion/grants", headers={**auth_headers_admin, "Origin": binding["origin"]}, json=binding + ) + assert issued.status_code == 200 + if permission_change == "role": + admin_user.role = UserRole.EDITOR + elif permission_change == "disabled": + admin_user.is_active = False + elif permission_change == "expired": + admin_user.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1) + else: + admin_user.token_version += 1 + session.add(admin_user) + session.commit() + response = client.post("/api/admin/settings/directory-index/companion/grants/redeem", json={**binding, "grant": issued.json()["grant"]}) + assert response.status_code == 403 + assert session.exec(select(CompanionDirectoryPolicyGrant)).one().consumed_at is None + + +def test_directory_policy_grant_ledger_expiry_and_browser_tokens_are_refused(client, auth_headers_admin, admin_token, session): + from app.models.companion_uri_token_jti import CompanionDirectoryPolicyGrant + + binding = _policy_grant_binding() + issued = client.post( + "/api/admin/settings/directory-index/companion/grants", headers={**auth_headers_admin, "Origin": binding["origin"]}, json=binding + ) + assert issued.status_code == 200 + ordinary = client.post("/api/admin/settings/directory-index/companion/grants/redeem", json={**binding, "grant": admin_token}) + assert ordinary.status_code in {401, 403} + record = session.exec(select(CompanionDirectoryPolicyGrant)).one() + record.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1) + session.add(record) + session.commit() + expired = client.post("/api/admin/settings/directory-index/companion/grants/redeem", json={**binding, "grant": issued.json()["grant"]}) + assert expired.status_code == 409 + session.refresh(record) + assert record.consumed_at is None + + +@pytest.mark.parametrize( + "field,value", [("challenge", "b" * 32), ("companion_id", "00000000-0000-0000-0000-000000000000"), ("origin", "https://other.example")] +) +def test_directory_policy_grant_rejects_changed_bindings(client, auth_headers_admin, field, value): + binding = _policy_grant_binding() + issued = client.post( + "/api/admin/settings/directory-index/companion/grants", headers={**auth_headers_admin, "Origin": binding["origin"]}, json=binding + ).json() + binding[field] = value + assert ( + client.post("/api/admin/settings/directory-index/companion/grants/redeem", json={**binding, "grant": issued["grant"]}).status_code + == 403 + ) + + +@pytest.mark.parametrize("claim", ["purpose", "token_class", "scope", "backend_identity"]) +def test_directory_policy_grant_refuses_signed_claim_substitution_without_consumption(client, auth_headers_admin, claim): + from app.core.security import create_access_token, decode_access_token + + binding = _policy_grant_binding() + issued = client.post( + "/api/admin/settings/directory-index/companion/grants", headers={**auth_headers_admin, "Origin": binding["origin"]}, json=binding + ).json() + claims = decode_access_token(issued["grant"]) + assert claims is not None + claims[claim] = "substituted" + substituted = create_access_token(data=claims) + assert ( + client.post("/api/admin/settings/directory-index/companion/grants/redeem", json={**binding, "grant": substituted}).status_code + == 403 + ) + assert ( + client.post("/api/admin/settings/directory-index/companion/grants/redeem", json={**binding, "grant": issued["grant"]}).status_code + == 200 + ) + + +def test_directory_policy_grant_cannot_authenticate_browser_or_deliver_extra_policy(client, auth_headers_admin): + binding = _policy_grant_binding() + issued = client.post( + "/api/admin/settings/directory-index/companion/grants", headers={**auth_headers_admin, "Origin": binding["origin"]}, json=binding + ).json() + assert client.get("/api/auth/me", headers={"Authorization": "Bearer " + issued["grant"]}).status_code == 401 + assert ( + client.post( + "/api/admin/settings/directory-index/companion/grants/redeem", + json={**binding, "grant": issued["grant"], "policy": {"disk_budget_bytes": 1}}, + ).status_code + == 422 + ) + + +def test_directory_policy_grant_rejects_nonadministrator_and_nonloopback_http(client, auth_headers_user): + binding = _policy_grant_binding() + assert ( + client.post( + "/api/admin/settings/directory-index/companion/grants", headers={**auth_headers_user, "Origin": binding["origin"]}, json=binding + ).status_code + == 403 + ) + binding["origin"] = "http://untrusted.example" + assert ( + client.post("/api/admin/settings/directory-index/companion/grants/redeem", json={**binding, "grant": "invalid"}).status_code == 422 + ) + + +def test_directory_policy_grant_rejects_changed_desired_revision(client, auth_headers_admin): + binding = _policy_grant_binding() + issued = client.post( + "/api/admin/settings/directory-index/companion/grants", headers={**auth_headers_admin, "Origin": binding["origin"]}, json=binding + ).json() + client.put("/api/admin/settings/directory-index/companion", headers=auth_headers_admin, json={"field": "page_cache_kib", "value": 1024}) + assert ( + client.post("/api/admin/settings/directory-index/companion/grants/redeem", json={**binding, "grant": issued["grant"]}).status_code + == 409 + ) + + def test_policy_blob_migration_materializes_missing_keys_and_keeps_existing_values() -> None: engine = create_engine("sqlite://") try: diff --git a/companion/package-lock.json b/companion/package-lock.json index aa62f64af..5589989ab 100644 --- a/companion/package-lock.json +++ b/companion/package-lock.json @@ -1,12 +1,12 @@ { "name": "sambee-companion", - "version": "2.0.26", + "version": "2.0.27", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sambee-companion", - "version": "2.0.26", + "version": "2.0.27", "dependencies": { "@tauri-apps/api": "^2.12.4", "@tauri-apps/plugin-autostart": "^2.7.0", diff --git a/companion/package.json b/companion/package.json index f585d8372..e9bb81845 100644 --- a/companion/package.json +++ b/companion/package.json @@ -1,6 +1,6 @@ { "name": "sambee-companion", - "version": "2.0.26", + "version": "2.0.27", "private": true, "type": "module", "scripts": { diff --git a/companion/scripts/recovery-smoke.py b/companion/scripts/recovery-smoke.py new file mode 100644 index 000000000..652ee5f92 --- /dev/null +++ b/companion/scripts/recovery-smoke.py @@ -0,0 +1,173 @@ +"""Check a Windows verification package through its actual application startup.""" + +from __future__ import annotations + +import argparse +import json +import os +import secrets +import socket +import subprocess +import tempfile +import time +import urllib.error +import urllib.request +from pathlib import Path + +API_PORT = 21549 +API_URL = f"http://127.0.0.1:{API_PORT}" +STARTUP_TIMEOUT_SECONDS = 45 +RECOVERY_TIMEOUT_SECONDS = 60 +REQUEST_TIMEOUT_SECONDS = 3 +SHUTDOWN_TIMEOUT_SECONDS = 15 +POLL_INTERVAL_SECONDS = 0.2 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--executable", type=Path, required=True) + parser.add_argument("--report-dir", type=Path, required=True) + parser.add_argument("--source-sha", required=True) + parser.add_argument("--run-id", required=True) + args = parser.parse_args() + args.report_dir.mkdir(parents=True, exist_ok=True) + report = { + "source_sha": args.source_sha, + "run_id": args.run_id, + "platform": "windows-x64", + "checks": [], + "passed": False, + } + process = None + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + token = secrets.token_hex(32) + + def request(action: str): + health = action == "health" + url = ( + f"{API_URL}/api/health" if health else f"{API_URL}/__verification/{action}" + ) + message = urllib.request.Request( + url, method="GET" if health else "POST", headers={"x-recovery-token": token} + ) + with opener.open(message, timeout=REQUEST_TIMEOUT_SECONDS) as response: + return json.load(response) + + def wait_for(check, seconds: int): + deadline = time.monotonic() + seconds + last_error = None + while time.monotonic() < deadline: + try: + result = check() + if result: + return result + except (OSError, urllib.error.URLError) as error: + last_error = type(error).__name__ + time.sleep(POLL_INTERVAL_SECONDS) + raise TimeoutError( + f"Recovery check exceeded {seconds} seconds. Last request failure: {last_error}" + ) + + def record(name: str): + report["checks"].append(name) + + try: + if os.name != "nt": + raise RuntimeError( + "Run this packaged recovery check on Windows x64 CI only." + ) + with socket.socket() as reservation: + reservation.bind(("127.0.0.1", API_PORT)) + with tempfile.TemporaryDirectory(prefix="sambee-recovery-") as temporary: + source = Path(temporary) / "source" + (source / "retained-reports").mkdir(parents=True) + environment = { + **os.environ, + "SAMBEE_RECOVERY_ROOT": str(source), + "SAMBEE_RECOVERY_TOKEN": token, + } + with (args.report_dir / "process.log").open( + "w", encoding="utf-8" + ) as output: + process = subprocess.Popen( + [str(args.executable.resolve()), "--from-autostart"], + env=environment, + stdout=output, + stderr=output, + ) + try: + wait_for(lambda: request("health"), STARTUP_TIMEOUT_SECONDS) + record("actual-application-startup") + + def healthy_contains(path): + response = request("search") + return ( + response.get("monitoring_state") == "healthy" + and response.get("coverage") == "complete" + and path in response["results"] + ) + + wait_for( + lambda: healthy_contains("retained-reports"), + RECOVERY_TIMEOUT_SECONDS, + ) + record("initial-native-index-and-watcher") + assert request("subscribe")["subscriptions"] == 1 + request("lose") + degraded = request("search") + assert degraded["monitoring_state"] == "unavailable", degraded + assert "retained-reports" in degraded["results"], degraded + record("watcher-loss-retains-results") + (source / "gap-reports").mkdir() + request("restore") + wait_for( + lambda: healthy_contains("gap-reports"), + RECOVERY_TIMEOUT_SECONDS, + ) + record("event-gap-reconciled-before-healthy") + (source / "native-reports").mkdir() + wait_for( + lambda: healthy_contains("native-reports"), + RECOVERY_TIMEOUT_SECONDS, + ) + record("native-events-after-recovery") + assert request("unsubscribe")["subscriptions"] == 0 + record("unsubscribe-releases-watcher") + wait_for( + lambda: request("stop")["stopped"], SHUTDOWN_TIMEOUT_SECONDS + ) + request("shutdown") + assert process.wait(timeout=SHUTDOWN_TIMEOUT_SECONDS) == 0 + record("clean-shutdown") + report["passed"] = True + finally: + if process.poll() is None: + try: + subprocess.run( + ["taskkill", "/PID", str(process.pid), "/T", "/F"], + check=True, + capture_output=True, + timeout=SHUTDOWN_TIMEOUT_SECONDS, + ) + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=SHUTDOWN_TIMEOUT_SECONDS) + except ( + AssertionError, + OSError, + RuntimeError, + ValueError, + subprocess.SubprocessError, + ) as error: + report["error"] = f"{type(error).__name__}: {error}" + finally: + (args.report_dir / "recovery.json").write_text( + json.dumps(report, indent=2) + "\n", encoding="utf-8" + ) + print(json.dumps(report, indent=2)) + return 0 if report["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/companion/src-tauri/Cargo.lock b/companion/src-tauri/Cargo.lock index 7de8df4ea..cc650c86a 100644 --- a/companion/src-tauri/Cargo.lock +++ b/companion/src-tauri/Cargo.lock @@ -266,7 +266,7 @@ dependencies = [ "dirs 6.0.0", "os_info", "smappservice-rs", - "thiserror 2.0.18", + "thiserror 2.0.21", "windows-registry", "windows-result 0.4.1", ] @@ -554,7 +554,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -1234,6 +1234,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -1778,6 +1790,24 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "heck" version = "0.4.1" @@ -2267,7 +2297,7 @@ dependencies = [ "jsonptr", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -2392,6 +2422,17 @@ dependencies = [ "redox_syscall 0.7.3", ] +[[package]] +name = "libsqlite3-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2524,7 +2565,7 @@ dependencies = [ "once_cell", "png 0.18.1", "serde", - "thiserror 2.0.18", + "thiserror 2.0.21", "windows-sys 0.61.2", ] @@ -2982,7 +3023,7 @@ dependencies = [ "objc2-osa-kit", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -3348,7 +3389,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tracing", "web-time", @@ -3370,7 +3411,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.21", "tinyvec", "tracing", "web-time", @@ -3498,7 +3539,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -3675,6 +3716,30 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsqlite-vfs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353b74f9810d5b085b6c42c9d86a5f0fdb795553235cdd48753b32e3809d18a7" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "rusqlite" +version = "0.41.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da9bba6f3506135e120a6586df05be18b48d18896600646a4bec8f993d91a594" +dependencies = [ + "bitflags 2.11.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rust-ini" version = "0.21.1" @@ -3802,7 +3867,7 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "sambee-companion" -version = "2.0.26" +version = "2.0.27" dependencies = [ "axum", "base64 0.22.1", @@ -3821,6 +3886,7 @@ dependencies = [ "notify", "png 0.17.16", "reqwest 0.12.28", + "rusqlite", "rust-ini", "serde", "serde_json", @@ -3836,7 +3902,7 @@ dependencies = [ "tauri-plugin-store", "tauri-plugin-updater", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tokio-util", "tower", @@ -4252,7 +4318,7 @@ dependencies = [ "objc2", "objc2-foundation", "objc2-service-management", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -4319,6 +4385,16 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "sqlite-wasm-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36bd8e29bf08189a005b2df1df99823aa0861cabe9044e5f31ed54ca292ee618" +dependencies = [ + "cc", + "rsqlite-vfs", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4393,6 +4469,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -4556,7 +4643,7 @@ dependencies = [ "tauri-runtime", "tauri-runtime-wry", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tray-icon", "url", @@ -4607,7 +4694,7 @@ dependencies = [ "sha2", "syn 2.0.117", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.21", "time", "url", "uuid", @@ -4655,7 +4742,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -4672,7 +4759,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.21", "tracing", "url", "windows-registry", @@ -4693,7 +4780,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-plugin-fs", - "thiserror 2.0.18", + "thiserror 2.0.21", "url", ] @@ -4716,7 +4803,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.21", "toml 1.1.2+spec-1.1.0", "url", ] @@ -4739,7 +4826,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-plugin-fs", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "url", "urlpattern", @@ -4763,7 +4850,7 @@ dependencies = [ "shared_child", "tauri", "tauri-plugin", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "windows-sys 0.60.2", ] @@ -4778,7 +4865,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin-deep-link", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tracing", "windows-sys 0.61.2", @@ -4796,7 +4883,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tracing", ] @@ -4827,7 +4914,7 @@ dependencies = [ "tauri", "tauri-plugin", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.21", "time", "tokio", "url", @@ -4853,7 +4940,7 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.21", "url", "webkit2gtk", "webview2-com", @@ -4916,7 +5003,7 @@ dependencies = [ "serde_json", "serde_with", "swift-rs", - "thiserror 2.0.18", + "thiserror 2.0.21", "toml 1.1.2+spec-1.1.0", "url", "urlpattern", @@ -4969,11 +5056,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.21", ] [[package]] @@ -4989,13 +5076,13 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] @@ -5342,7 +5429,7 @@ dependencies = [ "once_cell", "png 0.18.1", "serde", - "thiserror 2.0.18", + "thiserror 2.0.21", "windows-sys 0.61.2", ] @@ -5371,7 +5458,7 @@ dependencies = [ "log", "rand 0.9.3", "sha1", - "thiserror 2.0.18", + "thiserror 2.0.21", "utf-8", ] @@ -5504,6 +5591,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -5821,7 +5914,7 @@ version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3a07132775117d6065853d9d1178157b8c90e228de47129d6bce2c7edebedfb" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.21", "windows 0.62.2", "windows-core 0.62.2", ] @@ -6484,7 +6577,7 @@ dependencies = [ "sha2", "soup3", "tao-macros", - "thiserror 2.0.18", + "thiserror 2.0.21", "url", "webkit2gtk", "webkit2gtk-sys", diff --git a/companion/src-tauri/Cargo.toml b/companion/src-tauri/Cargo.toml index 1cdbc2191..0fcdb6981 100644 --- a/companion/src-tauri/Cargo.toml +++ b/companion/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sambee-companion" -version = "2.0.26" +version = "2.0.27" edition = "2021" description = "Sambee Companion — opens files from Sambee in native desktop editors" authors = [] @@ -9,6 +9,9 @@ authors = [] name = "sambee_companion_lib" crate-type = ["staticlib", "cdylib", "rlib"] +[features] +recovery-verification = [] + [build-dependencies] tauri-build = { version = "2", features = [] } @@ -50,6 +53,7 @@ bzip2 = "0.5" flate2 = { version = "1.1.9", default-features = false, features = ["zlib-rs"] } unicode-normalization = "0.1" unicode-casefold = "0.2.0" +rusqlite = { version = "0.41.1", features = ["bundled", "hooks"] } [dev-dependencies] tower = { version = "0.5", features = ["util"] } @@ -58,11 +62,15 @@ filetime = "0.2" [target.'cfg(windows)'.dependencies] winreg = "0.55" windows = { version = "0.61", features = [ + "Win32_Globalization", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", "Win32_System_Com", "Win32_Storage_FileSystem", + "Win32_System_IO", + "Win32_System_Ioctl", + "Win32_System_LibraryLoader", ] } png = "0.17" base64 = "0.22" diff --git a/companion/src-tauri/src/lib.rs b/companion/src-tauri/src/lib.rs index fdc5dbd37..b820a9b22 100644 --- a/companion/src-tauri/src/lib.rs +++ b/companion/src-tauri/src/lib.rs @@ -14,6 +14,8 @@ mod sync; mod token; mod uri; +pub use server::directory_index; + use log::{debug, error, info, warn}; use std::sync::{Arc, RwLock}; use tauri::{ @@ -1043,6 +1045,8 @@ pub fn run() { .manage(Arc::new(PairingState::new())) .manage(Arc::new(LocalizationState::new())) .invoke_handler(tauri::generate_handler![ + server::directory_policy::get_native_directory_policy_status, + server::directory_policy::reset_native_directory_policy_owner, commands::app_picker::get_apps_for_file, commands::app_picker::consume_pending_app_picker, commands::localization::get_synced_localization, diff --git a/companion/src-tauri/src/server/directory_index.rs b/companion/src-tauri/src/server/directory_index.rs new file mode 100644 index 000000000..48099ec2f --- /dev/null +++ b/companion/src-tauri/src/server/directory_index.rs @@ -0,0 +1,4071 @@ +//! Disk-backed directory storage. Managers own source binding, private storage, and worker admission. + +use std::cmp::Reverse; +use std::collections::{BTreeSet, BinaryHeap, HashMap}; +use std::fs::{self, File, OpenOptions}; +#[cfg(unix)] +use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, OnceLock}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use rusqlite::{params, Connection, OpenFlags, OptionalExtension}; +use serde::{Deserialize, Serialize}; + +pub const APPLICATION_ID: i64 = 1396853073; +pub const SCHEMA_VERSION: i64 = 1; +pub const NORMALIZATION_VERSION: i64 = 1; +pub const TRAVERSAL_VERSION: i64 = 1; +pub const MAX_QUERY_BYTES: usize = 4096; +pub const MAX_QUERY_TERMS: usize = 32; +pub const MAX_RESULT_LIMIT: usize = 200; +pub const ROW_BATCH_SIZE: usize = 256; +pub const SCAN_BATCH_BYTES: usize = 1_048_576; +pub const MAINTENANCE_PAGE_BATCH: i64 = 256; +pub const CHECKPOINT_WAL_BYTES: u64 = 1_048_576; +const INCREMENTAL_VACUUM_MODE: i64 = 2; +pub const PAGE_CACHE_KIB: i64 = 2048; +pub const SQL_BUSY_TIMEOUT: Duration = Duration::from_millis(250); +pub const QUERY_TIMEOUT: Duration = Duration::from_secs(10); +pub const SQL_PROGRESS_STEPS: i32 = 1000; +pub const MIN_SQLITE_VERSION_NUMBER: i32 = 3_051_003; +pub const SCHEMA: &str = include_str!("../../../../shared/directory-index/schema.sql"); +const FTS_SCHEMA: &str = include_str!("../../../../shared/directory-index/fts.sql"); +const GENERATION_VALIDATION: &str = include_str!("../../../../shared/directory-index/validate-generations.sql"); +pub const UNICODE_CONTRACT: &str = include_str!("../../../../shared/directory-index/unicode.json"); +#[cfg(unix)] +const PRIVATE_DIRECTORY_MODE: u32 = 0o700; +#[cfg(unix)] +const PRIVATE_FILE_MODE: u32 = 0o600; +const LEASE_EXTENSION: &str = ".lease"; +const NAMESPACE_LEASE_NAME: &str = ".namespace.lease"; +pub(super) const QUARANTINE_FILE_NAME: &str = ".rejected-directory-index"; +const STORAGE_FILE_SUFFIXES: [&str; 5] = ["", "-wal", "-shm", "-journal", LEASE_EXTENSION]; +const REQUIRED_DIRECTORY_TABLES: [&str; 5] = ["source_metadata", "generations", "directories", "scan_queue", "coverage_issues"]; + +pub(super) fn acquire_storage(path: &Path) -> Result { + let directory = path.parent().ok_or(IndexError::Invalid( + "Directory-index path has no parent. Select private local storage.", + ))?; + let _namespace_lease = acquire_namespace_lease(directory)?; + validate_storage_files(path)?; + acquire_file_lease(&storage_member(path, LEASE_EXTENSION)) +} + +fn storage_member(path: &Path, suffix: &str) -> PathBuf { + let mut filename = path.as_os_str().to_owned(); + filename.push(suffix); + filename.into() +} + +pub(super) fn validate_storage_files(path: &Path) -> Result<(), IndexError> { + for extension in STORAGE_FILE_SUFFIXES { + let mut filename = path.as_os_str().to_os_string(); + filename.push(extension); + match fs::symlink_metadata(Path::new(&filename)) { + Ok(metadata) if !metadata.is_file() || metadata.file_type().is_symlink() => { + return Err(IndexError::Invalid( + "Directory-index files must be regular files without symbolic links. Correct the storage path.", + )); + } + Ok(metadata) => { + #[cfg(unix)] + if metadata.nlink() != 1 { + return Err(IndexError::Invalid( + "Directory-index files must not use hard links. Remove the alias or select new private cache storage, then retry.", + )); + } + #[cfg(unix)] + if metadata.uid() != unsafe { libc::geteuid() } || metadata.permissions().mode() & 0o022 != 0 { + return Err(IndexError::Invalid("Directory-index files must belong to the current user without public write access. Correct their owner and permissions.")); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + use windows::Win32::Storage::FileSystem::{FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, FILE_SHARE_WRITE}; + + let file = OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ.0 | FILE_SHARE_WRITE.0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0) + .open(&filename)?; + validate_windows_storage_file(&file)?; + } + #[cfg(not(unix))] + let _ = metadata; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Ok(()) +} + +#[cfg(windows)] +fn validate_windows_storage_file(file: &File) -> Result<(), IndexError> { + use std::os::windows::io::AsRawHandle; + use windows::Win32::Foundation::HANDLE; + use windows::Win32::Storage::FileSystem::{GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, FILE_ATTRIBUTE_REPARSE_POINT}; + let mut information = BY_HANDLE_FILE_INFORMATION::default(); + // SAFETY: The file handle and output structure remain live for this call. + unsafe { GetFileInformationByHandle(HANDLE(file.as_raw_handle()), &mut information) } + .map_err(|_| IndexError::Invalid("Directory file identity is unavailable. Select private local storage and retry."))?; + if information.nNumberOfLinks != 1 || information.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 { + return Err(IndexError::Invalid( + "Directory-index files must not use links or redirections. Remove the alias or select private local storage.", + )); + } + Ok(()) +} + +pub(super) struct StorageDirectory { + path: PathBuf, + handle: File, +} + +impl StorageDirectory { + pub(super) fn open(path: &Path) -> Result { + validate_storage_directory(path)?; + let directory = Self { + path: path.to_owned(), + handle: Self::open_handle(path)?, + }; + directory.verify()?; + Ok(directory) + } + + fn open_handle(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + options.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + use windows::Win32::Storage::FileSystem::{ + FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, FILE_SHARE_WRITE, + }; + options + .share_mode(FILE_SHARE_READ.0 | FILE_SHARE_WRITE.0) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS.0 | FILE_FLAG_OPEN_REPARSE_POINT.0); + } + Ok(options.open(path)?) + } + + pub(super) fn verify(&self) -> Result<(), IndexError> { + validate_storage_directory(&self.path)?; + #[cfg(unix)] + { + let held = self.handle.metadata()?; + let current = fs::symlink_metadata(&self.path)?; + if (held.dev(), held.ino()) != (current.dev(), current.ino()) { + return Err(IndexError::Invalid( + "Directory storage changed during cleanup. Restore the private namespace and retry.", + )); + } + } + #[cfg(windows)] + { + use std::os::windows::io::AsRawHandle; + use windows::Win32::Foundation::HANDLE; + use windows::Win32::Storage::FileSystem::{FileIdInfo, GetFileInformationByHandleEx, FILE_ID_INFO}; + let current = Self::open_handle(&self.path)?; + let mut held_info = FILE_ID_INFO::default(); + let mut current_info = FILE_ID_INFO::default(); + // SAFETY: Both handles and output structures remain live for these calls. + unsafe { + GetFileInformationByHandleEx( + HANDLE(self.handle.as_raw_handle()), + FileIdInfo, + std::ptr::from_mut(&mut held_info).cast(), + size_of::() as u32, + ) + .and_then(|()| { + GetFileInformationByHandleEx( + HANDLE(current.as_raw_handle()), + FileIdInfo, + std::ptr::from_mut(&mut current_info).cast(), + size_of::() as u32, + ) + }) + } + .map_err(|_| IndexError::Invalid("Directory storage identity is unavailable. Select private local storage and retry."))?; + if (held_info.VolumeSerialNumber, held_info.FileId.Identifier) + != (current_info.VolumeSerialNumber, current_info.FileId.Identifier) + { + return Err(IndexError::Invalid( + "Directory storage changed during cleanup. Restore the private namespace and retry.", + )); + } + } + Ok(()) + } + + fn member(&self, path: &Path) -> Result { + if path.parent() != Some(self.path.as_path()) { + return Err(IndexError::Invalid("Directory cleanup target is outside its held namespace.")); + } + std::ffi::CString::new( + path.file_name() + .ok_or(IndexError::Invalid("Directory cleanup target has no file name."))? + .as_encoded_bytes(), + ) + .map_err(|_| IndexError::Invalid("Directory cleanup target has an unsupported native name.")) + } + + pub(super) fn remove(&self, path: &Path) -> Result<(), IndexError> { + self.verify()?; + let name = self.member(path)?; + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + // SAFETY: The directory descriptor and NUL-terminated member name remain live. + if unsafe { libc::unlinkat(self.handle.as_raw_fd(), name.as_ptr(), 0) } != 0 { + return Err(std::io::Error::last_os_error().into()); + } + } + #[cfg(not(unix))] + { + let _ = name; + fs::remove_file(path)?; + } + Ok(()) + } + + fn rename(&self, original: &Path, target: &Path) -> Result<(), IndexError> { + self.verify()?; + let original_name = self.member(original)?; + let target_name = self.member(target)?; + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + // SAFETY: Both member names and the held directory descriptor remain live. + if unsafe { + libc::renameat( + self.handle.as_raw_fd(), + original_name.as_ptr(), + self.handle.as_raw_fd(), + target_name.as_ptr(), + ) + } != 0 + { + return Err(std::io::Error::last_os_error().into()); + } + } + #[cfg(not(unix))] + { + let _ = (original_name, target_name); + fs::rename(original, target)?; + } + Ok(()) + } +} + +fn clear_quarantine_owned(path: &Path) -> Result { + let directory = StorageDirectory::open( + path.parent() + .ok_or(IndexError::Invalid("Directory quarantine has no private parent."))?, + )?; + validate_storage_files(path)?; + let mut removed = false; + for suffix in STORAGE_FILE_SUFFIXES.into_iter().filter(|suffix| *suffix != LEASE_EXTENSION) { + match directory.remove(&storage_member(path, suffix)) { + Ok(()) => removed = true, + Err(IndexError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + } + Ok(removed) +} + +pub(super) fn discard_quarantine(directory: &Path) -> Result { + let path = directory.join(QUARANTINE_FILE_NAME); + let mut exists = false; + for suffix in STORAGE_FILE_SUFFIXES.into_iter().filter(|suffix| *suffix != LEASE_EXTENSION) { + exists |= storage_member(&path, suffix).try_exists()?; + } + if !exists { + return Ok(false); + } + let _namespace_lease = acquire_namespace_lease(directory)?; + let _lease = acquire_file_lease(&storage_member(&path, LEASE_EXTENSION))?; + clear_quarantine_owned(&path) +} + +fn quarantine_owned(path: &Path, _source_lease: &File) -> Result<(), IndexError> { + let directory = path + .parent() + .ok_or(IndexError::Invalid("Directory-index path has no storage parent."))?; + let _namespace_lease = acquire_namespace_lease(directory)?; + let storage_directory = StorageDirectory::open(directory)?; + let rejected = directory.join(QUARANTINE_FILE_NAME); + let _lease = acquire_file_lease(&storage_member(&rejected, LEASE_EXTENSION))?; + validate_storage_files(path)?; + clear_quarantine_owned(&rejected)?; + let mut moved: Vec<(PathBuf, PathBuf)> = Vec::new(); + for suffix in ["-wal", "-shm", "-journal", ""] { + let original = storage_member(path, suffix); + let target = storage_member(&rejected, suffix); + match storage_directory.rename(&original, &target) { + Ok(()) => moved.push((original, target)), + Err(IndexError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + for (original, target) in moved.into_iter().rev() { + if storage_directory.rename(&target, &original).is_err() { + log::warn!("Directory recovery restore failed. Reconnect after restoring private storage."); + } + } + return Err(error); + } + } + } + Ok(()) +} + +pub(super) fn remove_orphan_lease(path: &Path) -> Result { + let directory = path + .parent() + .ok_or(IndexError::Invalid("Directory-index path has no storage parent."))?; + let _namespace_lease = acquire_namespace_lease(directory)?; + let storage_directory = StorageDirectory::open(directory)?; + let mut lease_path = path.as_os_str().to_owned(); + lease_path.push(LEASE_EXTENSION); + let lease = acquire_file_lease(Path::new(&lease_path))?; + for extension in ["", "-wal", "-shm", "-journal"] { + let mut filename = path.as_os_str().to_owned(); + filename.push(extension); + match fs::symlink_metadata(Path::new(&filename)) { + Ok(_) => return Ok(false), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + drop(lease); + storage_directory.remove(Path::new(&lease_path))?; + Ok(true) +} + +fn validate_storage_directory(directory: &Path) -> Result<(), IndexError> { + for ancestor in directory.ancestors() { + match fs::symlink_metadata(ancestor) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { + return Err(IndexError::Invalid( + "Directory-index storage must use real directories without symbolic links. Select private local storage.", + )) + } + Ok(metadata) => { + #[cfg(unix)] + { + let mode = metadata.permissions().mode(); + let owner = unsafe { libc::geteuid() }; + if (metadata.uid() != owner && metadata.uid() != 0) || (mode & 0o022 != 0 && mode & 0o1000 == 0) { + return Err(IndexError::Invalid( + "Directory-index ancestors must have trusted owners and no shared write access without a sticky parent. Correct storage permissions.", + )); + } + if ancestor == directory && (metadata.uid() != owner || mode & 0o077 != 0) { + return Err(IndexError::Invalid( + "Directory-index storage must belong to the current user with mode 0700. Correct its owner and permissions.", + )); + } + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + use windows::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 { + return Err(IndexError::Invalid( + "Directory-index storage ancestors must not use redirections. Select private local storage.", + )); + } + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Ok(()) +} + +pub(super) fn open_readonly_directory_database(path: &Path) -> Result { + open_directory_database(path, OpenFlags::SQLITE_OPEN_READ_ONLY) +} + +pub(super) fn open_directory_database(path: &Path, access: OpenFlags) -> Result { + let directory = path + .parent() + .ok_or(IndexError::Invalid("Directory database has no private storage parent."))?; + let guard = StorageDirectory::open(directory)?; + validate_storage_directory(directory)?; + validate_storage_files(path)?; + let before = fs::symlink_metadata(path)?; + let connection = Connection::open_with_flags(path, access | OpenFlags::SQLITE_OPEN_NOFOLLOW)?; + guard.verify()?; + validate_storage_directory(directory)?; + validate_storage_files(path)?; + let after = fs::symlink_metadata(path)?; + #[cfg(unix)] + if (before.dev(), before.ino()) != (after.dev(), after.ino()) { + return Err(IndexError::Invalid( + "Directory database changed during open. Restore private storage and reconnect the source.", + )); + } + #[cfg(not(unix))] + let _ = (before, after); + Ok(connection) +} + +pub(super) fn acquire_namespace_lease(directory: &Path) -> Result { + validate_storage_directory(directory)?; + let mut builder = fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + builder.mode(PRIVATE_DIRECTORY_MODE); + builder.create(directory)?; + validate_storage_directory(directory)?; + acquire_file_lease(&directory.join(NAMESPACE_LEASE_NAME)) +} + +pub(super) fn acquire_file_lease(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + options.mode(PRIVATE_FILE_MODE).custom_flags(libc::O_NOFOLLOW); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + use windows::Win32::Storage::FileSystem::{FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, FILE_SHARE_WRITE}; + options + .share_mode(FILE_SHARE_READ.0 | FILE_SHARE_WRITE.0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0); + } + let lease = options.open(path)?; + #[cfg(windows)] + validate_windows_storage_file(&lease)?; + let metadata = lease.metadata()?; + if !metadata.is_file() { + return Err(IndexError::Invalid( + "Directory-index lease must be a private regular file. Correct its storage path.", + )); + } + #[cfg(unix)] + if metadata.nlink() != 1 { + return Err(IndexError::Invalid( + "Directory-index leases must not use hard links. Remove the alias or select new private cache storage, then retry.", + )); + } + #[cfg(unix)] + if metadata.uid() != unsafe { libc::geteuid() } || metadata.permissions().mode() & 0o022 != 0 { + return Err(IndexError::Invalid( + "Directory-index lease must belong to the current user without public write access. Correct its owner and permissions.", + )); + } + lease.try_lock().map_err(|error| match error { + fs::TryLockError::WouldBlock => std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "Another process owns this directory index. Close the other instance and retry.", + ), + fs::TryLockError::Error(error) => error, + })?; + Ok(lease) +} + +#[derive(Deserialize)] +struct UnicodeContract { + #[serde(deserialize_with = "deserialize_normalization")] + normalization: HashMap, + whitespace: BTreeSet, + word_ranges: Vec<(u32, u32)>, +} + +fn deserialize_normalization<'de, Deserializer: serde::Deserializer<'de>>( + deserializer: Deserializer, +) -> Result, Deserializer::Error> { + Ok(Vec::<(char, String)>::deserialize(deserializer)?.into_iter().collect()) +} + +fn unicode_contract() -> &'static UnicodeContract { + static CONTRACT: OnceLock = OnceLock::new(); + CONTRACT.get_or_init(|| serde_json::from_str(UNICODE_CONTRACT).expect("Embedded search Unicode contract must pass contract tests")) +} + +fn is_word_character(character: char) -> bool { + let codepoint = u32::from(character); + unicode_contract() + .word_ranges + .binary_search_by(|(start, end)| { + if codepoint < *start { + std::cmp::Ordering::Greater + } else if codepoint > *end { + std::cmp::Ordering::Less + } else { + std::cmp::Ordering::Equal + } + }) + .is_ok() +} + +#[derive(Debug, thiserror::Error)] +pub enum IndexError { + #[error("Directory database operation failed. Check local storage and retry: {0}")] + Sql(rusqlite::Error), + #[error("Directory database filesystem operation failed. Check local storage and retry: {0}")] + Io(std::io::Error), + #[error("{0}")] + Invalid(&'static str), + #[error("{0}")] + RebuildRequired(&'static str), + #[error("{0}")] + StoragePaused(&'static str), + #[error("Directory query workers are busy. Wait briefly and retry.")] + QueryBusy, + #[error("Directory search was cancelled or timed out. Retry the search.")] + Interrupted, +} + +fn sqlite_failure_fields(error: &rusqlite::Error) -> (&'static str, i32) { + match error { + rusqlite::Error::SqliteFailure(code, _) => ( + match code.code { + rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked => "directory_storage_busy", + rusqlite::ErrorCode::OperationInterrupted => "directory_query_cancelled", + _ => "directory_transaction_failed", + }, + code.extended_code, + ), + _ => ("directory_transaction_failed", 0), + } +} + +impl From for IndexError { + fn from(error: rusqlite::Error) -> Self { + let (event, code) = sqlite_failure_fields(&error); + log::debug!("event={event} sqlite_code={code}"); + Self::Sql(error) + } +} + +impl From for IndexError { + fn from(error: std::io::Error) -> Self { + log::debug!("event=directory_storage_failed os_code={}", error.raw_os_error().unwrap_or(0)); + Self::Io(error) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SourceKind { + Smb, + Local, +} + +impl SourceKind { + fn as_str(self) -> &'static str { + match self { + Self::Smb => "smb", + Self::Local => "local", + } + } +} + +pub fn normalize_search_text(value: &str) -> String { + let mut normalized = String::with_capacity(value.len()); + for character in value.chars() { + if let Some(key) = unicode_contract().normalization.get(&character) { + normalized.push_str(key); + } else { + normalized.push(character); + } + } + normalized +} + +pub fn search_terms(query: &str) -> Result, IndexError> { + if query.len() > MAX_QUERY_BYTES { + return Err(IndexError::Invalid( + "Search text exceeds the UTF-8 byte limit. Shorten the search text.", + )); + } + let terms: BTreeSet<_> = query + .split(|character| unicode_contract().whitespace.contains(&character)) + .map(normalize_search_text) + .filter(|term| !term.is_empty()) + .collect(); + if terms.len() > MAX_QUERY_TERMS { + return Err(IndexError::Invalid("Search text has too many distinct terms. Remove some terms.")); + } + Ok(terms.into_iter().collect()) +} + +pub fn single_term_rank(basename_key: &str, term: &str) -> i64 { + if basename_key == term { + return 0; + } + if basename_key.starts_with(term) { + return 1; + } + if basename_key + .char_indices() + .any(|(offset, character)| !is_word_character(character) && basename_key[offset + character.len_utf8()..].starts_with(term)) + { + return 2; + } + 3 +} + +pub fn validate_directory_path(path: &str) -> Result<(), IndexError> { + if path.contains('\0') || path.split('/').any(|part| matches!(part, "" | "." | "..")) { + return Err(IndexError::Invalid( + "Directory path must be relative without empty, dot, parent, or NUL components.", + )); + } + Ok(()) +} + +pub(super) fn epoch_milliseconds() -> Result { + let elapsed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| IndexError::Invalid("System clock is invalid. Correct the system clock."))?; + i64::try_from(elapsed.as_millis()) + .map_err(|_| IndexError::Invalid("System clock exceeds the supported timestamp range. Correct the system clock.")) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DirectoryIndexMatch { + pub path: String, + pub matched_term_count: usize, + pub match_rank: i64, + pub depth: i64, + pub path_key: String, +} + +impl DirectoryIndexMatch { + fn sort_key(&self) -> (Reverse, i64, i64, &str, &str) { + ( + Reverse(self.matched_term_count), + self.match_rank, + self.depth, + &self.path_key, + &self.path, + ) + } +} + +impl Ord for DirectoryIndexMatch { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.sort_key().cmp(&other.sort_key()) + } +} + +impl PartialOrd for DirectoryIndexMatch { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +#[derive(Debug, Eq, PartialEq)] +pub struct DirectoryIndexSearch { + pub results: Vec, + pub total_matches: i64, + pub directory_count: i64, + pub accepted_revision: i64, + pub coverage: String, + pub last_verified_at_ms: Option, + pub query_plan: &'static str, + pub issue_count: i64, +} + +pub struct DirectoryIndex { + path: PathBuf, + writer: Connection, + page_cache_kib: i64, + fts_ready: Arc, + fts_repair_cursor: Option, + _lease: File, +} + +pub enum DirectoryMutation { + ReadParent { path: String }, + Add { path: String }, + Remove { path: String }, + Rename { path: String, new_path: String }, +} + +fn checked_sql_size(value: usize) -> Result { + i64::try_from(value).map_err(|_| IndexError::Invalid("Directory-index size exceeds the supported signed integer range.")) +} + +impl DirectoryIndex { + pub(super) fn bind_volume(&mut self, current: &super::drives::VolumeEvidence) -> Result { + use super::drives::{VolumeChange, VolumeEvidence}; + let transaction = self.writer.transaction()?; + let identity: String = transaction.query_row("SELECT source_identity FROM source_metadata WHERE id = 1", [], |row| row.get(0))?; + let mut identity: serde_json::Value = + serde_json::from_str(&identity).map_err(|_| IndexError::Invalid("Directory source metadata is invalid."))?; + let previous = identity + .get("evidence") + .cloned() + .map(serde_json::from_value::) + .transpose() + .map_err(|_| IndexError::Invalid("Directory volume metadata is invalid."))?; + let change = previous.as_ref().map_or(VolumeChange::Keep, |previous| previous.compare(current)); + if matches!(change, VolumeChange::Index | VolumeChange::Unavailable) { + return Ok(change); + } + if change == VolumeChange::Generation { + transaction.execute("UPDATE generations SET state = 'retired' WHERE state <> 'retired'", [])?; + transaction.execute("INSERT INTO generations(state, coverage) VALUES ('building', 'partial')", [])?; + let generation = transaction.last_insert_rowid(); + transaction.execute( + "UPDATE source_metadata SET selected_generation = ?, last_verified_at_ms = NULL WHERE id = 1", + [generation], + )?; + Self::advance_revision(&transaction)?; + } + let mut evidence = current.clone(); + if let Some(previous) = previous { + evidence.retain_available(&previous); + } + identity["evidence"] = + serde_json::to_value(evidence).map_err(|_| IndexError::Invalid("Directory volume metadata cannot be saved."))?; + transaction.execute( + "UPDATE source_metadata SET source_identity = ? WHERE id = 1", + [identity.to_string()], + )?; + transaction.commit()?; + Ok(change) + } + + pub fn open(path: &Path, source_kind: SourceKind, source_identity: &serde_json::Value) -> Result { + Self::open_with_recovery(path, source_kind, source_identity, None) + } + + pub(super) fn open_with_recovery( + path: &Path, + source_kind: SourceKind, + source_identity: &serde_json::Value, + recovery_attempted: Option<&AtomicBool>, + ) -> Result { + if rusqlite::version_number() < MIN_SQLITE_VERSION_NUMBER { + return Err(IndexError::Invalid( + "Directory indexing requires a patched SQLite runtime. Rebuild the companion from its pinned dependencies.", + )); + } + let absolute_path = std::path::absolute(path)?; + let path = absolute_path.as_path(); + let lease = acquire_storage(path)?; + let (writer, fts_ready) = match Self::open_owned(path, source_kind, source_identity) { + Ok(storage) => storage, + Err(error) => { + let rebuildable = match &error { + IndexError::RebuildRequired(_) => true, + IndexError::Sql(rusqlite::Error::SqliteFailure(code, _)) => { + matches!(code.code, rusqlite::ErrorCode::DatabaseCorrupt | rusqlite::ErrorCode::NotADatabase) + } + _ => false, + }; + let Some(attempted) = recovery_attempted.filter(|_| rebuildable) else { + return Err(error); + }; + if attempted + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Err(IndexError::Invalid( + "Directory recovery already failed after replacement. Restore private storage capacity and permissions, then reconnect the drive.", + )); + } + quarantine_owned(path, &lease)?; + let storage = Self::open_owned(path, source_kind, source_identity)?; + log::debug!("event=directory_storage_recovered"); + storage + } + }; + log::debug!("event=directory_storage_initialized"); + Ok(Self { + path: path.to_path_buf(), + writer, + page_cache_kib: PAGE_CACHE_KIB, + fts_ready: Arc::new(AtomicBool::new(fts_ready)), + fts_repair_cursor: None, + _lease: lease, + }) + } + + fn open_owned(path: &Path, source_kind: SourceKind, source_identity: &serde_json::Value) -> Result<(Connection, bool), IndexError> { + let directory = StorageDirectory::open( + path.parent() + .ok_or(IndexError::Invalid("Directory database has no private parent."))?, + )?; + let created = !path.try_exists()?; + if created { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + options.mode(PRIVATE_FILE_MODE).custom_flags(libc::O_NOFOLLOW); + drop(options.open(path)?); + } + directory.verify()?; + let mut writer = open_directory_database(path, OpenFlags::SQLITE_OPEN_READ_WRITE)?; + writer.busy_timeout(SQL_BUSY_TIMEOUT)?; + if created { + writer.execute_batch(SCHEMA)?; + writer.execute_batch(FTS_SCHEMA)?; + let now = epoch_milliseconds()?; + let transaction = writer.transaction()?; + transaction.execute("INSERT INTO generations(id, state, coverage) VALUES (1, 'building', 'partial')", [])?; + transaction.execute( + "INSERT INTO source_metadata(id, source_kind, source_identity, normalization_version, traversal_version, selected_generation, committed_at_ms, last_access_at_ms) VALUES (1, ?, ?, ?, ?, 1, ?, ?)", + params![source_kind.as_str(), source_identity.to_string(), NORMALIZATION_VERSION, TRAVERSAL_VERSION, now, now], + )?; + transaction.commit()?; + } + let application_id: i64 = writer.query_row("PRAGMA application_id", [], |row| row.get(0))?; + let schema_version: i64 = writer.query_row("PRAGMA user_version", [], |row| row.get(0))?; + if application_id != APPLICATION_ID { + return Err(IndexError::Invalid( + "Directory database application identifier is invalid. Rebuild this source index.", + )); + } + if schema_version != SCHEMA_VERSION { + return Err(IndexError::RebuildRequired( + "Directory database schema is unsupported. Rebuild this source index.", + )); + } + for table in REQUIRED_DIRECTORY_TABLES { + if !writer.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?)", + [table], + |row| row.get::<_, bool>(0), + )? { + return Err(IndexError::RebuildRequired( + "Directory database tables are missing. Rebuild this source index.", + )); + } + } + let metadata: Option<(String, String, i64, i64)> = writer + .query_row( + "SELECT source_kind, source_identity, normalization_version, traversal_version FROM source_metadata WHERE id = 1", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .optional()?; + let Some((kind, identity, normalization, traversal)) = metadata else { + return Err(IndexError::RebuildRequired( + "Directory database source metadata is missing. Rebuild this source index.", + )); + }; + let mut stored_identity = serde_json::from_str::(&identity).ok(); + if source_kind == SourceKind::Local { + if let Some(serde_json::Value::Object(identity)) = stored_identity.as_mut() { + identity.remove("evidence"); + } + } + if kind != source_kind.as_str() || stored_identity.as_ref() != Some(source_identity) { + return Err(IndexError::Invalid( + "Directory database source or policy does not match. Rebuild this source index.", + )); + } + if normalization != NORMALIZATION_VERSION || traversal != TRAVERSAL_VERSION { + return Err(IndexError::RebuildRequired( + "Directory database source or policy does not match. Rebuild this source index.", + )); + } + let integrity: String = writer.query_row("PRAGMA quick_check", [], |row| row.get(0))?; + if integrity != "ok" || writer.prepare("PRAGMA foreign_key_check")?.query([])?.next()?.is_some() { + return Err(IndexError::RebuildRequired( + "Directory database integrity check failed. Rebuild this source index.", + )); + } + let invalid_metadata: bool = writer.query_row(GENERATION_VALIDATION, [], |row| row.get(0))?; + if invalid_metadata { + return Err(IndexError::RebuildRequired( + "Directory database generation metadata is invalid. Rebuild this source index.", + )); + } + writer.execute_batch("PRAGMA foreign_keys = ON; PRAGMA synchronous = FULL; PRAGMA mmap_size = 0; PRAGMA temp_store = FILE;")?; + let journal_mode: String = writer.query_row("PRAGMA journal_mode = WAL", [], |row| row.get(0))?; + if journal_mode != "wal" { + return Err(IndexError::Invalid( + "Directory-index storage does not support WAL. Select supported private local storage.", + )); + } + writer.pragma_update(None, "cache_size", -PAGE_CACHE_KIB)?; + let fts_ready = match Self::prepare_fts(&writer) { + Err(IndexError::Sql(rusqlite::Error::SqliteFailure(code, _))) + if matches!(code.code, rusqlite::ErrorCode::DatabaseCorrupt | rusqlite::ErrorCode::NotADatabase) => + { + return Err(IndexError::StoragePaused( + "Directory search token repair could not start. Validated directory data was retained. Restore private storage and retry the drive.", + )); + } + result => result?, + }; + let transaction = writer.transaction()?; + transaction.execute( + "UPDATE source_metadata SET last_access_at_ms = ? WHERE id = 1", + [epoch_milliseconds()?], + )?; + transaction.execute( + "UPDATE generations SET state = 'retired' WHERE state = 'candidate' AND id <> (SELECT selected_generation FROM source_metadata WHERE id = 1)", + [], + )?; + transaction.commit()?; + Ok((writer, fts_ready)) + } + + fn prepare_fts(writer: &Connection) -> Result { + writer.execute_batch(FTS_SCHEMA)?; + match writer.execute("INSERT INTO directory_fts(directory_fts, rank) VALUES ('integrity-check', 1)", []) { + Ok(_) => Ok(true), + Err(rusqlite::Error::SqliteFailure(error, _)) if error.code == rusqlite::ErrorCode::DatabaseCorrupt => { + writer.execute("INSERT INTO directory_fts(directory_fts) VALUES ('delete-all')", [])?; + Ok(false) + } + Err(error) => Err(error.into()), + } + } + + fn advance_revision(transaction: &rusqlite::Transaction<'_>) -> Result<(), IndexError> { + let revision: i64 = transaction.query_row("SELECT accepted_revision FROM source_metadata WHERE id = 1", [], |row| row.get(0))?; + let next = revision.checked_add(1).ok_or(IndexError::Invalid( + "Directory-index revision is exhausted. Rebuild this source index.", + ))?; + transaction.execute( + "UPDATE source_metadata SET accepted_revision = ?, committed_at_ms = ? WHERE id = 1", + params![next, epoch_milliseconds()?], + )?; + Ok(()) + } + + pub fn write_paths(&mut self, paths: impl IntoIterator, generation_id: Option) -> Result<(), IndexError> { + self.require_fts()?; + let mut iterator = paths.into_iter(); + let mut pending = None; + loop { + let mut batch = Vec::new(); + let mut bytes = 0; + while batch.len() < ROW_BATCH_SIZE { + let Some(path) = pending.take().or_else(|| iterator.next()) else { + break; + }; + if path.len() > SCAN_BATCH_BYTES { + return Err(IndexError::Invalid( + "Directory path exceeds the scan-batch byte limit. Source coverage is incomplete.", + )); + } + if path.len() > SCAN_BATCH_BYTES - bytes { + pending = Some(path); + break; + } + bytes += path.len(); + batch.push(path); + } + if batch.is_empty() { + return Ok(()); + } + let transaction = self.writer.transaction()?; + let selected: i64 = + transaction.query_row("SELECT selected_generation FROM source_metadata WHERE id = 1", [], |row| row.get(0))?; + let target = generation_id.unwrap_or(selected); + if generation_id.is_some() { + let state: String = transaction.query_row("SELECT state FROM generations WHERE id = ?", [target], |row| row.get(0))?; + if !matches!(state.as_str(), "building" | "candidate") { + return Err(IndexError::Invalid( + "Scan generation is no longer active. Discard this batch and request verification.", + )); + } + } + { + let mut statement = transaction.prepare("INSERT INTO directories(generation_id, path, path_key, basename_key, depth, has_dot_directory) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(generation_id, path) DO NOTHING")?; + for path in batch { + validate_directory_path(&path)?; + let depth = i64::try_from(path.bytes().filter(|byte| *byte == b'/').count()) + .map_err(|_| IndexError::Invalid("Directory depth exceeds the supported range."))?; + let dot = path.split('/').any(|part| part.starts_with('.')); + statement.execute(params![ + target, + path, + normalize_search_text(&path), + normalize_search_text(path.rsplit('/').next().unwrap_or(&path)), + depth, + dot + ])?; + } + } + if target == selected { + Self::advance_revision(&transaction)?; + } + transaction.commit()?; + } + } + + fn require_scan_generation(transaction: &rusqlite::Transaction<'_>, generation_id: i64) -> Result<(), IndexError> { + let state: String = transaction.query_row("SELECT state FROM generations WHERE id = ?", [generation_id], |row| row.get(0))?; + if !matches!(state.as_str(), "building" | "candidate") { + return Err(IndexError::Invalid( + "Scan generation is no longer active. Discard this batch and request verification.", + )); + } + Ok(()) + } + + fn require_reconciliation_parent(transaction: &rusqlite::Transaction<'_>, generation_id: i64, parent: &str) -> Result<(), IndexError> { + let pending: bool = transaction.query_row( + "SELECT EXISTS(SELECT 1 FROM generations g JOIN source_metadata m ON m.selected_generation = g.id JOIN scan_queue q ON q.generation_id = g.id WHERE g.id = ? AND g.state = 'accepted' AND q.path = ? AND q.processed = 0)", + params![generation_id, parent], + |row| row.get(0), + )?; + if !pending { + return Err(IndexError::Invalid( + "Reconciliation parent is not pending on the selected generation. Request source verification.", + )); + } + Ok(()) + } + + pub fn seed_scan(&mut self, generation_id: i64) -> Result<(), IndexError> { + let transaction = self.writer.transaction()?; + Self::require_scan_generation(&transaction, generation_id)?; + transaction.execute( + "INSERT INTO scan_queue(generation_id, path) VALUES (?, '') ON CONFLICT DO NOTHING", + [generation_id], + )?; + transaction.commit()?; + Ok(()) + } + + pub fn pending_scan_paths(&self, generation_id: i64, limit: usize) -> Result, IndexError> { + if limit == 0 || limit > ROW_BATCH_SIZE { + return Err(IndexError::Invalid("Scan queue batch limit is unsupported. Use a bounded batch.")); + } + let mut statement = self + .writer + .prepare("SELECT path FROM scan_queue WHERE generation_id = ? AND processed = 0 ORDER BY rowid LIMIT ?")?; + let rows = statement.query_map(params![generation_id, checked_sql_size(limit)?], |row| row.get(0))?; + Ok(rows.collect::, _>>()?) + } + + pub(super) fn requeue_completed_scan_batch(&mut self, generation_id: i64) -> Result { + let transaction = self.writer.transaction()?; + Self::require_scan_generation(&transaction, generation_id)?; + let changed = transaction.execute( + "UPDATE scan_queue SET processed = 0 WHERE rowid IN (SELECT rowid FROM scan_queue WHERE generation_id = ? AND processed = 1 LIMIT ?)", + params![generation_id, checked_sql_size(ROW_BATCH_SIZE)?], + )?; + transaction.commit()?; + Ok(changed == ROW_BATCH_SIZE) + } + + pub fn write_scan_children( + &mut self, + generation_id: i64, + children: impl IntoIterator, + ) -> Result<(), IndexError> { + self.write_children(generation_id, children, None) + } + + pub fn queue_reconciliation_parent(&mut self, generation_id: i64, parent: &str) -> Result<(), IndexError> { + if !parent.is_empty() { + validate_directory_path(parent)?; + } + let transaction = self.writer.transaction()?; + let accepted: bool = transaction.query_row( + "SELECT EXISTS(SELECT 1 FROM generations g JOIN source_metadata m ON m.selected_generation = g.id WHERE g.id = ? AND g.state = 'accepted')", + [generation_id], + |row| row.get(0), + )?; + if !accepted { + return Err(IndexError::Invalid( + "Reconciliation generation is no longer selected. Request source verification.", + )); + } + transaction.execute( + "INSERT INTO scan_queue(generation_id, path) VALUES (?, ?) ON CONFLICT(generation_id, path) DO UPDATE SET processed = 0", + params![generation_id, parent], + )?; + transaction.commit()?; + Ok(()) + } + + pub fn finish_reconciliation_parent(&mut self, generation_id: i64, parent: &str) -> Result<(), IndexError> { + let transaction = self.writer.transaction()?; + Self::require_reconciliation_parent(&transaction, generation_id, parent)?; + transaction.execute( + "UPDATE scan_queue SET processed = 1 WHERE generation_id = ? AND path = ?", + params![generation_id, parent], + )?; + transaction.commit()?; + Ok(()) + } + + pub fn write_reconciliation_children( + &mut self, + generation_id: i64, + parent: &str, + children: impl IntoIterator, + ) -> Result<(), IndexError> { + if !parent.is_empty() { + validate_directory_path(parent)?; + } + self.write_children(generation_id, children, Some(parent)) + } + + fn write_children( + &mut self, + generation_id: i64, + children: impl IntoIterator, + reconciliation_parent: Option<&str>, + ) -> Result<(), IndexError> { + self.require_fts()?; + let mut batch = Vec::new(); + let mut batch_bytes = 0; + for child in children.into_iter().take(ROW_BATCH_SIZE + 1) { + if reconciliation_parent.is_some() { + if child.0.len() > SCAN_BATCH_BYTES - batch_bytes { + return Err(IndexError::Invalid( + "Reconciliation children exceed the scan-batch byte limit. Split them into bounded batches.", + )); + } + batch_bytes += child.0.len(); + } + batch.push(child); + } + if batch.len() > ROW_BATCH_SIZE { + return Err(IndexError::Invalid( + "Scan children exceed the batch limit. Split them into bounded batches.", + )); + } + let transaction = self.writer.transaction()?; + let prefix = if let Some(parent) = reconciliation_parent { + Self::require_reconciliation_parent(&transaction, generation_id, parent)?; + if parent.is_empty() { + String::new() + } else { + format!("{parent}/") + } + } else { + Self::require_scan_generation(&transaction, generation_id)?; + String::new() + }; + let mut changed = false; + for (path, descend) in batch { + validate_directory_path(&path)?; + if reconciliation_parent.is_some() + && !path + .strip_prefix(&prefix) + .is_some_and(|child| !child.is_empty() && !child.contains('/')) + { + return Err(IndexError::Invalid( + "Reconciliation child is outside its immediate parent. Request source verification.", + )); + } + let inserted = transaction.execute("INSERT INTO directories(generation_id, path, path_key, basename_key, depth, has_dot_directory) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(generation_id, path) DO NOTHING", params![generation_id, path, normalize_search_text(&path), normalize_search_text(path.rsplit('/').next().unwrap_or(&path)), checked_sql_size(path.bytes().filter(|byte| *byte == b'/').count())?, path.split('/').any(|part| part.starts_with('.'))])?; + changed |= inserted != 0; + if descend && (reconciliation_parent.is_none() || inserted != 0) { + transaction.execute( + "INSERT INTO scan_queue(generation_id, path) VALUES (?, ?) ON CONFLICT DO NOTHING", + params![generation_id, path], + )?; + } + } + let selected: i64 = transaction.query_row("SELECT selected_generation FROM source_metadata WHERE id = 1", [], |row| row.get(0))?; + if generation_id == selected && (reconciliation_parent.is_none() || changed) { + Self::advance_revision(&transaction)?; + } + transaction.commit()?; + Ok(()) + } + + pub fn direct_child_paths( + &self, + generation_id: i64, + parent: &str, + after_path: Option<&str>, + maximum_bytes: usize, + ) -> Result, IndexError> { + if !parent.is_empty() { + validate_directory_path(parent)?; + } + if let Some(cursor) = after_path { + validate_directory_path(cursor)?; + if cursor.rsplit_once('/').map_or("", |(parent, _)| parent) != parent { + return Err(IndexError::Invalid("Child-page cursor must belong to the requested parent.")); + } + } + if maximum_bytes == 0 || maximum_bytes > SCAN_BATCH_BYTES { + return Err(IndexError::Invalid( + "Child-page byte budget is outside the supported scan-batch range.", + )); + } + let prefix = if parent.is_empty() { String::new() } else { format!("{parent}/") }; + let lower = after_path.unwrap_or(&prefix).max(&prefix); + let upper = (!parent.is_empty()).then(|| format!("{parent}0")); + let mut statement = self.writer.prepare(if upper.is_some() { + "SELECT path FROM directories WHERE generation_id = ?1 AND path >= ?2 AND path < ?3 ORDER BY path" + } else { + "SELECT path FROM directories WHERE generation_id = ?1 AND path >= ?2 ORDER BY path" + })?; + let mut rows = if let Some(upper) = upper.as_ref() { + statement.query(params![generation_id, lower, upper])? + } else { + statement.query(params![generation_id, lower])? + }; + let mut result = BTreeSet::new(); + let mut bytes = 0; + let mut overflow: Option = None; + let mut frontier: Option = None; + while let Some(row) = rows.next()? { + let stored_path: String = row.get(0)?; + if frontier.as_ref().is_some_and(|boundary| &stored_path >= boundary) { + break; + } + let suffix = &stored_path[prefix.len()..]; + let path = format!("{prefix}{}", suffix.split('/').next().unwrap_or(suffix)); + if after_path.is_some_and(|after| path.as_str() <= after) + || overflow.as_ref().is_some_and(|excluded| &path >= excluded) + || result.contains(&path) + { + continue; + } + bytes += path.len(); + result.insert(path); + while result.len() > ROW_BATCH_SIZE || bytes > maximum_bytes { + let excluded = result + .pop_last() + .ok_or(IndexError::Invalid("Directory child-page accounting is invalid."))?; + bytes -= excluded.len(); + overflow = Some(excluded); + } + let ceiling = overflow + .as_ref() + .or_else(|| if result.len() == ROW_BATCH_SIZE { result.last() } else { None }); + if let Some(ceiling) = ceiling { + let name = &ceiling[prefix.len()..]; + let cut = name + .char_indices() + .find_map(|(offset, character)| (offset > 0 && character < '/').then_some(offset)) + .unwrap_or(name.len()); + frontier = Some(format!("{prefix}{}0", &name[..cut])); + } + } + if result.len() < ROW_BATCH_SIZE && overflow.is_some_and(|excluded| excluded.len() > maximum_bytes) { + return Err(IndexError::Invalid( + "Directory child exceeds the scan-page byte budget. Source coverage is incomplete.", + )); + } + Ok(result.into_iter().collect()) + } + + pub fn prune_subtree_batch(&mut self, generation_id: i64, path: &str) -> Result { + validate_directory_path(path)?; + self.require_fts()?; + let prefix = format!("{path}/"); + let transaction = self.writer.transaction()?; + let state: Option = transaction + .query_row("SELECT state FROM generations WHERE id = ?", [generation_id], |row| row.get(0)) + .optional()?; + if !matches!(state.as_deref(), Some("building" | "candidate" | "accepted")) { + return Err(IndexError::Invalid( + "Reconciliation generation is no longer active. Discard this observation and request verification.", + )); + } + let removed = transaction.execute( + "DELETE FROM directories WHERE id IN (SELECT id FROM directories WHERE generation_id = ? AND (path = ? OR substr(path, 1, length(?)) = ?) ORDER BY id LIMIT ?)", + params![generation_id, path, prefix, prefix, checked_sql_size(ROW_BATCH_SIZE)?], + )?; + let queued = transaction.execute( + "DELETE FROM scan_queue WHERE generation_id = ? AND path IN (SELECT path FROM scan_queue WHERE generation_id = ? AND (path = ? OR substr(path, 1, length(?)) = ?) ORDER BY path LIMIT ?)", + params![generation_id, generation_id, path, prefix, prefix, checked_sql_size(ROW_BATCH_SIZE)?], + )?; + let selected: i64 = transaction.query_row("SELECT selected_generation FROM source_metadata WHERE id = 1", [], |row| row.get(0))?; + if (removed != 0 || queued != 0) && generation_id == selected { + Self::advance_revision(&transaction)?; + } + let remains = transaction.query_row( + "SELECT EXISTS(SELECT 1 FROM directories WHERE generation_id = ? AND (path = ? OR substr(path, 1, length(?)) = ?)) OR EXISTS(SELECT 1 FROM scan_queue WHERE generation_id = ? AND (path = ? OR substr(path, 1, length(?)) = ?))", + params![generation_id, path, prefix, prefix, generation_id, path, prefix, prefix], |row| row.get(0), + )?; + transaction.commit()?; + Ok(remains) + } + + pub fn finish_scan_parent(&mut self, generation_id: i64, path: &str) -> Result<(), IndexError> { + if !path.is_empty() { + validate_directory_path(path)?; + } + let transaction = self.writer.transaction()?; + Self::require_scan_generation(&transaction, generation_id)?; + transaction.execute( + "UPDATE scan_queue SET processed = 1 WHERE generation_id = ? AND path = ?", + params![generation_id, path], + )?; + transaction.commit()?; + Ok(()) + } + + pub fn discard_generation(&mut self, generation_id: i64) -> Result<(), IndexError> { + self.writer.execute( + "UPDATE generations SET state = 'retired' WHERE id = ? AND id <> (SELECT selected_generation FROM source_metadata WHERE id = 1)", + [generation_id], + )?; + Ok(()) + } + + pub(super) fn page_cache_target_kib(&self) -> i64 { + self.page_cache_kib + } + + pub fn touch_access(&mut self, accessed_at_ms: i64) -> Result<(), IndexError> { + if accessed_at_ms < 0 { + return Err(IndexError::Invalid( + "Directory-index access time is invalid. Check the host clock and retry.", + )); + } + self.writer + .execute("UPDATE source_metadata SET last_access_at_ms = ? WHERE id = 1", [accessed_at_ms])?; + Ok(()) + } + + pub fn checkpoint_due(&self) -> Result { + let mut wal = self.path.as_os_str().to_owned(); + wal.push("-wal"); + let metadata = match fs::symlink_metadata(PathBuf::from(wal)) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error.into()), + }; + if !metadata.is_file() { + return Err(IndexError::Invalid( + "Directory-index WAL must be a regular file. Correct private cache storage and retry.", + )); + } + Ok(metadata.len() >= CHECKPOINT_WAL_BYTES) + } + + pub fn checkpoint_if_due(&self) -> Result { + if !self.checkpoint_due()? { + return Ok(true); + } + let (busy, total, completed): (i64, i64, i64) = self.writer.query_row("PRAGMA wal_checkpoint(PASSIVE)", [], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?)) + })?; + if busy != 0 || total != completed { + log::debug!("event=directory_checkpoint_deferred busy={busy} total={total} completed={completed}"); + return Ok(false); + } + let (busy, total, completed): (i64, i64, i64) = self.writer.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?)) + })?; + log::debug!("event=directory_checkpoint_completed busy={busy} total={total} completed={completed}"); + Ok(busy == 0 && total == completed) + } + + pub fn reclaim_pages(&mut self) -> Result { + let vacuum: i64 = self.writer.query_row("PRAGMA auto_vacuum", [], |row| row.get(0))?; + if vacuum != INCREMENTAL_VACUUM_MODE { + return Err(IndexError::Invalid( + "Directory storage does not support incremental reclamation. Rebuild this source index.", + )); + } + let free: i64 = self.writer.query_row("PRAGMA freelist_count", [], |row| row.get(0))?; + if free == 0 { + return Ok(true); + } + let checkpoint = |connection: &Connection, command: &str| -> Result { + let (busy, total, completed): (i64, i64, i64) = + connection.query_row(command, [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?; + Ok(busy == 0 && total == completed) + }; + if !checkpoint(&self.writer, "PRAGMA wal_checkpoint(PASSIVE)")? { + return Ok(false); + } + let mut statement = self + .writer + .prepare(&format!("PRAGMA incremental_vacuum({MAINTENANCE_PAGE_BATCH})"))?; + let mut pages = statement.query([])?; + while pages.next()?.is_some() {} + drop(pages); + drop(statement); + log::debug!("event=directory_pages_reclaimed"); + checkpoint(&self.writer, "PRAGMA wal_checkpoint(TRUNCATE)") + } + + pub fn cleanup_retired_batch(&mut self) -> Result { + self.require_fts()?; + let batch_size = i64::try_from(ROW_BATCH_SIZE).map_err(|_| IndexError::Invalid("Directory cleanup batch size is unsupported."))?; + let transaction = self.writer.transaction()?; + let retired: Option = transaction.query_row( + "SELECT id FROM generations WHERE state = 'retired' AND id <> (SELECT selected_generation FROM source_metadata WHERE id = 1) ORDER BY id LIMIT 1", + [], |row| row.get(0) + ).optional()?; + let Some(generation) = retired else { + return Ok(false); + }; + transaction.execute( + "DELETE FROM directories WHERE id IN (SELECT id FROM directories WHERE generation_id = ? ORDER BY id LIMIT ?)", + params![generation, batch_size], + )?; + transaction.execute( + "DELETE FROM scan_queue WHERE generation_id = ? AND path IN (SELECT path FROM scan_queue WHERE generation_id = ? ORDER BY path LIMIT ?)", + params![generation, generation, batch_size], + )?; + transaction.execute( + "DELETE FROM generations WHERE id = ? AND NOT EXISTS (SELECT 1 FROM directories WHERE generation_id = ?) AND NOT EXISTS (SELECT 1 FROM scan_queue WHERE generation_id = ?)", + params![generation, generation, generation], + )?; + let remains = transaction.query_row("SELECT EXISTS(SELECT 1 FROM generations WHERE state = 'retired')", [], |row| { + row.get(0) + })?; + transaction.commit()?; + Ok(remains) + } + + pub fn mutate_path(&mut self, mutation: &DirectoryMutation) -> Result<(), IndexError> { + self.apply_mutation(mutation, false) + } + + pub fn mutate_observed_path(&mut self, mutation: &DirectoryMutation) -> Result<(), IndexError> { + self.apply_mutation(mutation, true) + } + + pub fn accepted_generation(&self) -> Result, IndexError> { + Ok(self + .writer + .query_row( + "SELECT g.id FROM generations g JOIN source_metadata m ON m.selected_generation = g.id WHERE g.state = 'accepted'", + [], + |row| row.get(0), + ) + .optional()?) + } + + pub(super) fn mutation_generations(&self) -> Result, IndexError> { + let mut statement = self + .writer + .prepare("SELECT id FROM generations WHERE state IN ('building', 'accepted', 'candidate')")?; + let rows = statement.query_map([], |row| row.get(0))?; + Ok(rows.collect::>()?) + } + + pub(super) fn mutation_subtree_paths(&self, generation: i64, path: &str, after: Option<&str>) -> Result, IndexError> { + validate_directory_path(path)?; + let prefix = format!("{path}/"); + let upper = format!("{path}0"); + let mut statement = self.writer.prepare( + "SELECT path FROM directories WHERE generation_id = ?1 AND path >= ?2 AND path < ?3 AND (?4 IS NULL OR path > ?4) ORDER BY path LIMIT ?5", + )?; + let lower = after.unwrap_or(&prefix).max(&prefix); + let mut rows = statement.query(params![generation, lower, upper, after, checked_sql_size(ROW_BATCH_SIZE)?])?; + let mut paths = Vec::new(); + let mut bytes = 0; + while let Some(row) = rows.next()? { + let child: String = row.get(0)?; + if child.len() > SCAN_BATCH_BYTES { + return Err(IndexError::Invalid("Directory mutation path exceeds the scan-batch byte limit.")); + } + if child.len() > SCAN_BATCH_BYTES - bytes { + break; + } + bytes += child.len(); + paths.push(child); + } + Ok(paths) + } + + pub fn reconciliation_generation(&self) -> Result, IndexError> { + Ok(self.writer.query_row( + "SELECT g.id FROM generations g JOIN source_metadata m ON m.selected_generation = g.id WHERE g.state = 'accepted' AND EXISTS(SELECT 1 FROM scan_queue q WHERE q.generation_id = g.id AND q.processed = 0)", + [], + |row| row.get(0), + ).optional()?) + } + + fn apply_mutation(&mut self, mutation: &DirectoryMutation, reconcile: bool) -> Result<(), IndexError> { + self.require_fts()?; + let path = match mutation { + DirectoryMutation::Add { path } + | DirectoryMutation::Remove { path } + | DirectoryMutation::Rename { path, .. } + | DirectoryMutation::ReadParent { path } => path, + }; + if !matches!(mutation, DirectoryMutation::ReadParent { .. }) || !path.is_empty() { + validate_directory_path(path)?; + } + if let DirectoryMutation::Rename { new_path, .. } = mutation { + validate_directory_path(new_path)?; + if new_path == path { + return Ok(()); + } + if new_path.starts_with(&format!("{path}/")) { + return Err(IndexError::Invalid("A directory cannot be renamed into its own subtree.")); + } + } + let transaction = self.writer.transaction()?; + match mutation { + DirectoryMutation::ReadParent { path } => { + transaction.execute( + "INSERT INTO scan_queue(generation_id, path) SELECT id, ? FROM generations WHERE state IN ('building', 'candidate') ON CONFLICT(generation_id, path) DO UPDATE SET processed = 0", + [path], + )?; + } + DirectoryMutation::Add { path } => { + transaction.execute("INSERT INTO directories(generation_id, path, path_key, basename_key, depth, has_dot_directory) SELECT id, ?, ?, ?, ?, ? FROM generations WHERE state IN ('building', 'accepted', 'candidate') ON CONFLICT(generation_id, path) DO NOTHING", params![path, normalize_search_text(path), normalize_search_text(path.rsplit('/').next().unwrap_or(path)), checked_sql_size(path.bytes().filter(|byte| *byte == b'/').count())?, path.split('/').any(|part| part.starts_with('.'))])?; + transaction.execute( + "INSERT INTO scan_queue(generation_id, path) SELECT id, ? FROM generations WHERE state IN ('building', 'candidate') ON CONFLICT(generation_id, path) DO UPDATE SET processed = 0", + [path], + )?; + } + DirectoryMutation::Remove { path } => { + let prefix = format!("{path}/"); + transaction.execute("DELETE FROM directories WHERE generation_id IN (SELECT id FROM generations WHERE state IN ('building', 'accepted', 'candidate')) AND (path = ? OR substr(path, 1, length(?)) = ?)", params![path, prefix, prefix])?; + transaction.execute("DELETE FROM scan_queue WHERE generation_id IN (SELECT id FROM generations WHERE state IN ('building', 'accepted', 'candidate')) AND (path = ? OR substr(path, 1, length(?)) = ?)", params![path, prefix, prefix])?; + } + DirectoryMutation::Rename { path, new_path } => { + let prefix = format!("{path}/"); + let mut last_id: Option = None; + loop { + let batch: Vec<(i64, String)> = { + let mut statement = transaction.prepare("SELECT id, path FROM directories WHERE (? IS NULL OR id > ?) AND generation_id IN (SELECT id FROM generations WHERE state IN ('building', 'accepted', 'candidate')) AND (path = ? OR substr(path, 1, length(?)) = ?) ORDER BY id LIMIT ?")?; + let rows = statement.query_map( + params![last_id, last_id, path, prefix, prefix, checked_sql_size(ROW_BATCH_SIZE)?], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + rows.collect::, _>>()? + }; + if batch.is_empty() { + break; + } + for (identifier, original) in batch { + last_id = Some(identifier); + let renamed = format!("{new_path}{}", &original[path.len()..]); + transaction.execute("UPDATE directories SET path = ?, path_key = ?, basename_key = ?, depth = ?, has_dot_directory = ? WHERE id = ?", params![renamed, normalize_search_text(&renamed), normalize_search_text(renamed.rsplit('/').next().unwrap_or(&renamed)), checked_sql_size(renamed.bytes().filter(|byte| *byte == b'/').count())?, renamed.split('/').any(|part| part.starts_with('.')), identifier])?; + } + } + transaction.execute("UPDATE scan_queue SET path = ? || substr(path, length(?) + 1), processed = 0 WHERE generation_id IN (SELECT id FROM generations WHERE state IN ('building', 'accepted', 'candidate')) AND (path = ? OR substr(path, 1, length(?)) = ?)", params![new_path, path, path, prefix, prefix])?; + } + } + if reconcile { + let mut parents = Vec::new(); + match mutation { + DirectoryMutation::ReadParent { path } => parents.push((path.as_str(), true)), + DirectoryMutation::Add { path } => { + parents.push((path.as_str(), true)); + parents.push((path.rsplit_once('/').map_or("", |(parent, _)| parent), true)); + } + DirectoryMutation::Remove { path } => parents.push((path.rsplit_once('/').map_or("", |(parent, _)| parent), true)), + DirectoryMutation::Rename { path, new_path } => { + parents.push((path.rsplit_once('/').map_or("", |(parent, _)| parent), true)); + parents.push((new_path.rsplit_once('/').map_or("", |(parent, _)| parent), true)); + parents.push((new_path.as_str(), false)); + } + } + for (parent, unconditional) in parents { + transaction.execute( + "INSERT INTO scan_queue(generation_id, path) SELECT g.id, ? FROM generations g JOIN source_metadata m ON m.selected_generation = g.id WHERE g.state = 'accepted' AND (? OR EXISTS(SELECT 1 FROM directories d WHERE d.generation_id = g.id AND d.path = ?)) ON CONFLICT(generation_id, path) DO UPDATE SET processed = 0", + params![parent, unconditional, parent], + )?; + } + } + Self::advance_revision(&transaction)?; + transaction.commit()?; + Ok(()) + } + + pub fn begin_verification(&mut self) -> Result { + self.writer + .execute("INSERT INTO generations(state, coverage) VALUES ('candidate', 'partial')", [])?; + Ok(self.writer.last_insert_rowid()) + } + + pub fn prepare_scan(&mut self) -> Result<(i64, bool), IndexError> { + self.require_fts()?; + let transaction = self.writer.transaction()?; + let retained: Option = transaction + .query_row( + "SELECT id FROM generations WHERE state = 'candidate' AND issue_count = 0 ORDER BY id DESC LIMIT 1", + [], + |row| row.get(0), + ) + .optional()?; + if let Some(retained) = retained { + transaction.commit()?; + return Ok((retained, false)); + } + transaction.execute("UPDATE generations SET state = 'retired' WHERE state = 'candidate'", [])?; + let (generation, state, count, queued, issues): (i64, String, i64, bool, i64) = transaction.query_row( + "SELECT g.id, g.state, g.indexed_count, EXISTS(SELECT 1 FROM scan_queue q WHERE q.generation_id = g.id), g.issue_count FROM generations g JOIN source_metadata m ON m.selected_generation = g.id WHERE m.id = 1", + [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)) + )?; + if state == "building" + && count == 0 + && !queued + && (issues == 0 + || !transaction.query_row( + "SELECT EXISTS(SELECT 1 FROM coverage_issues WHERE generation_id = ? AND code <> 'event_loss')", + [generation], + |row| row.get::<_, bool>(0), + )?) + { + if issues != 0 { + transaction.execute( + "DELETE FROM coverage_issues WHERE generation_id = ? AND code = 'event_loss'", + [generation], + )?; + transaction.execute( + "UPDATE generations SET coverage = 'partial', issue_count = 0 WHERE id = ?", + [generation], + )?; + Self::advance_revision(&transaction)?; + } + transaction.commit()?; + return Ok((generation, true)); + } + transaction.execute("INSERT INTO generations(state, coverage) VALUES ('candidate', 'partial')", [])?; + let candidate = transaction.last_insert_rowid(); + transaction.commit()?; + Ok((candidate, false)) + } + + pub(super) fn has_resumable_scan(&self) -> Result { + Ok(self.writer.query_row( + "SELECT EXISTS(SELECT 1 FROM generations g WHERE (g.state = 'candidate' AND g.issue_count = 0) OR \ + (g.state = 'building' AND NOT EXISTS(SELECT 1 FROM generations WHERE state = 'candidate') AND \ + NOT EXISTS(SELECT 1 FROM coverage_issues WHERE generation_id = g.id AND code <> 'event_loss')))", + [], + |row| row.get(0), + )?) + } + + pub fn mark_uncertain(&mut self) -> Result<(), IndexError> { + let transaction = self.writer.transaction()?; + let generation: i64 = + transaction.query_row("SELECT selected_generation FROM source_metadata WHERE id = 1", [], |row| row.get(0))?; + transaction.execute("INSERT INTO coverage_issues(generation_id, code, count) VALUES (?, 'event_loss', 1) ON CONFLICT(generation_id, code) DO NOTHING", [generation])?; + let changed = transaction.execute( + "UPDATE generations SET coverage = 'uncertain', issue_count = (SELECT sum(count) FROM coverage_issues WHERE generation_id = ?) \ + WHERE id = ? AND (coverage <> 'uncertain' OR issue_count <> (SELECT sum(count) FROM coverage_issues WHERE generation_id = ?))", + params![generation, generation, generation], + )?; + if changed != 0 { + Self::advance_revision(&transaction)?; + } + transaction.commit()?; + Ok(()) + } + + pub fn record_issue(&mut self, generation: i64, code: &str) -> Result<(), IndexError> { + if !matches!( + code, + "access_denied" | "unsupported_encoding" | "unsafe_traversal" | "identity_unverified" | "event_loss" | "scan_failed" + ) { + return Err(IndexError::Invalid("Unsupported directory coverage issue.")); + } + let transaction = self.writer.transaction()?; + transaction.execute("INSERT INTO coverage_issues(generation_id, code, count) VALUES (?, ?, 1) ON CONFLICT(generation_id, code) DO UPDATE SET count = count + 1", params![generation, code])?; + transaction.execute("UPDATE generations SET issue_count = (SELECT sum(count) FROM coverage_issues WHERE generation_id = ?), coverage = CASE WHEN coverage = 'uncertain' THEN 'uncertain' ELSE 'partial' END WHERE id = ?", params![generation, generation])?; + let selected: i64 = transaction.query_row("SELECT selected_generation FROM source_metadata WHERE id = 1", [], |row| row.get(0))?; + if selected == generation { + Self::advance_revision(&transaction)?; + } + transaction.commit()?; + Ok(()) + } + + pub fn finish_generation(&mut self, generation_id: i64, issue_count: i64) -> Result { + if issue_count < 0 { + return Err(IndexError::Invalid("Coverage issue count must not be negative.")); + } + let transaction = self.writer.transaction()?; + let state: String = transaction.query_row("SELECT state FROM generations WHERE id = ?", [generation_id], |row| row.get(0))?; + if !matches!(state.as_str(), "candidate" | "building") { + return Err(IndexError::Invalid("Generation is not an active build or verification candidate.")); + } + let stored_issues: i64 = transaction.query_row( + "SELECT coalesce(sum(count), 0) FROM coverage_issues WHERE generation_id = ?", + [generation_id], + |row| row.get(0), + )?; + let additional_issues = issue_count.saturating_sub(stored_issues).max(0); + if additional_issues > 0 { + transaction.execute("INSERT INTO coverage_issues(generation_id, code, count) VALUES (?, 'scan_failed', ?) ON CONFLICT(generation_id, code) DO UPDATE SET count = count + excluded.count", params![generation_id, additional_issues])?; + } + let unresolved = issue_count.max(stored_issues); + if unresolved > 0 { + transaction.execute( + "UPDATE generations SET issue_count = ?, coverage = CASE WHEN coverage = 'uncertain' THEN 'uncertain' ELSE 'partial' END WHERE id = ?", + params![unresolved, generation_id], + )?; + let selected: i64 = + transaction.query_row("SELECT selected_generation FROM source_metadata WHERE id = 1", [], |row| row.get(0))?; + if selected == generation_id { + Self::advance_revision(&transaction)?; + } + transaction.commit()?; + log::debug!("event=directory_scan_issues issue_count={unresolved}"); + return Ok(false); + } + if transaction.query_row( + "SELECT EXISTS(SELECT 1 FROM scan_queue WHERE generation_id = ? AND processed = 0)", + [generation_id], + |row| row.get::<_, bool>(0), + )? { + return Err(IndexError::Invalid( + "Scan work remains queued. Complete the scan before selecting this generation.", + )); + } + transaction.execute("UPDATE generations SET state = 'retired' WHERE id = (SELECT selected_generation FROM source_metadata WHERE id = 1) AND id <> ?", [generation_id])?; + transaction.execute( + "UPDATE generations SET state = 'accepted', coverage = 'complete', issue_count = 0 WHERE id = ?", + [generation_id], + )?; + transaction.execute( + "UPDATE source_metadata SET selected_generation = ?, last_verified_at_ms = ? WHERE id = 1", + params![generation_id, epoch_milliseconds()?], + )?; + Self::advance_revision(&transaction)?; + transaction.commit()?; + log::debug!("event=directory_generation_accepted"); + Ok(true) + } + + pub fn search( + &self, + query: &str, + limit: usize, + include_dot: bool, + cancelled: &Arc, + force_scan: bool, + ) -> Result { + self.reader().search(query, limit, include_dot, cancelled, force_scan) + } + + pub fn reader(&self) -> DirectoryIndexReader { + DirectoryIndexReader { + path: self.path.clone(), + page_cache_kib: self.page_cache_kib, + fts_ready: Arc::clone(&self.fts_ready), + } + } + + pub fn needs_fts_repair(&self) -> bool { + !self.fts_ready.load(Ordering::Acquire) + } + + fn require_fts(&self) -> Result<(), IndexError> { + if self.needs_fts_repair() { + return Err(IndexError::Invalid( + "Directory search index is being rebuilt. Existing rows remain searchable; retry updates after repair.", + )); + } + Ok(()) + } + + pub fn repair_fts_batch(&mut self) -> Result { + if !self.needs_fts_repair() { + return Ok(false); + } + let transaction = self.writer.transaction()?; + let mut statement = transaction.prepare("SELECT id, path_key FROM directories WHERE ? IS NULL OR id > ? ORDER BY id LIMIT ?")?; + let rows = statement + .query_map( + params![self.fts_repair_cursor, self.fts_repair_cursor, checked_sql_size(ROW_BATCH_SIZE)?], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), + )? + .collect::, _>>()?; + drop(statement); + for (id, key) in &rows { + transaction.execute("INSERT INTO directory_fts(rowid, path_key) VALUES (?, ?)", params![id, key])?; + } + let complete = rows.len() < ROW_BATCH_SIZE; + if complete { + transaction.execute("INSERT INTO directory_fts(directory_fts, rank) VALUES ('integrity-check', 1)", [])?; + } + transaction.commit()?; + if let Some((id, _)) = rows.last() { + self.fts_repair_cursor = Some(*id); + } + self.fts_ready.store(complete, Ordering::Release); + Ok(!complete) + } + + pub fn configure_page_cache(&mut self, kib: i64) -> Result<(), IndexError> { + if kib <= 0 { + return Err(IndexError::Invalid("Directory page-cache target must be positive.")); + } + self.writer.pragma_update(None, "cache_size", -kib)?; + self.page_cache_kib = kib; + Ok(()) + } +} + +#[derive(Clone)] +pub struct DirectoryIndexReader { + path: PathBuf, + page_cache_kib: i64, + fts_ready: Arc, +} + +impl DirectoryIndexReader { + pub fn search( + &self, + query: &str, + limit: usize, + include_dot: bool, + cancelled: &Arc, + force_scan: bool, + ) -> Result { + let terms = search_terms(query)?; + if !(1..=MAX_RESULT_LIMIT).contains(&limit) { + return Err(IndexError::Invalid("Directory result limit is outside the supported range.")); + } + let started = Instant::now(); + let interrupted = || cancelled.load(Ordering::Acquire) || started.elapsed() >= QUERY_TIMEOUT; + if interrupted() { + return Err(IndexError::Interrupted); + } + let mut connection = open_readonly_directory_database(&self.path)?; + let cancellation = Arc::clone(cancelled); + connection.progress_handler( + SQL_PROGRESS_STEPS, + Some(move || cancellation.load(Ordering::Acquire) || started.elapsed() >= QUERY_TIMEOUT), + )?; + connection.busy_timeout(SQL_BUSY_TIMEOUT)?; + connection.execute_batch("PRAGMA mmap_size = 0; PRAGMA temp_store = FILE;")?; + connection.pragma_update(None, "cache_size", -self.page_cache_kib)?; + let fts_ready = self.fts_ready.load(Ordering::Acquire); + let transaction = connection.transaction()?; + let (generation, revision, verified): (i64, i64, Option) = transaction.query_row( + "SELECT selected_generation, accepted_revision, last_verified_at_ms FROM source_metadata WHERE id = 1", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )?; + let (directory_count, coverage, issue_count): (i64, String, i64) = transaction.query_row( + "SELECT indexed_count, coverage, issue_count FROM generations WHERE id = ?", + [generation], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )?; + let mut response = DirectoryIndexSearch { + results: Vec::new(), + total_matches: 0, + directory_count, + accepted_revision: revision, + coverage, + last_verified_at_ms: verified, + query_plan: "empty", + issue_count, + }; + if terms.is_empty() { + return Ok(response); + } + let trigram = fts_ready && !force_scan && terms.iter().all(|term| term.chars().count() >= 3); + response.query_plan = if trigram { "trigram" } else { "scan" }; + let mut sql = String::from("SELECT directories.path, directories.path_key, directories.basename_key, directories.depth "); + if trigram { + sql.push_str("FROM directory_fts CROSS JOIN directories WHERE directories.id = directory_fts.rowid AND directories.generation_id = ? AND (? OR directories.has_dot_directory = 0) AND directory_fts MATCH ?"); + } else { + sql.push_str("FROM directories WHERE directories.generation_id = ? AND (? OR directories.has_dot_directory = 0)"); + } + let expression = terms + .iter() + .map(|term| format!("\"{}\"", term.replace('"', "\"\""))) + .collect::>() + .join(" OR "); + let mut statement = transaction.prepare(&sql)?; + let mut rows = if trigram { + statement.query(params![generation, include_dot, expression])? + } else { + statement.query(params![generation, include_dot])? + }; + let mut best = BinaryHeap::with_capacity(limit); + while let Some(row) = rows.next()? { + if interrupted() { + return Err(IndexError::Interrupted); + } + let path_key: String = row.get(1)?; + let basename_key: String = row.get(2)?; + let matched: Vec<_> = terms.iter().filter(|term| path_key.contains(term.as_str())).collect(); + if matched.is_empty() { + continue; + } + response.total_matches = response + .total_matches + .checked_add(1) + .ok_or(IndexError::Invalid("Directory match count exceeds the supported range."))?; + let result = DirectoryIndexMatch { + path: row.get(0)?, + matched_term_count: matched.len(), + match_rank: matched.iter().map(|term| single_term_rank(&basename_key, term)).min().unwrap_or(3), + depth: row.get(3)?, + path_key, + }; + if best.len() < limit { + best.push(result); + } else if best.peek().is_some_and(|worst| result < *worst) { + best.pop(); + best.push(result); + } + } + response.results = best.into_sorted_vec(); + Ok(response) + } +} + +#[cfg(test)] +mod tests { + #[test] + fn removable_evidence_retires_rows_exactly_once_and_missing_evidence_keeps_them() { + use super::super::drives::{VolumeChange, VolumeEvidence}; + use super::super::models::DriveType; + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("evidence.sqlite3"); + let identity = serde_json::json!({"root": "fixture"}); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &identity).unwrap(); + let mut evidence = VolumeEvidence { + class: DriveType::Removable, + volume: Some("first".into()), + label: Some("label".into()), + ..Default::default() + }; + assert_eq!(index.bind_volume(&evidence).unwrap(), VolumeChange::Keep); + index.write_paths(["old".to_owned()], None).unwrap(); + index.finish_generation(1, 0).unwrap(); + evidence.label = Some("renamed".into()); + assert_eq!(index.bind_volume(&evidence).unwrap(), VolumeChange::Generation); + let selected: i64 = index + .writer + .query_row("SELECT selected_generation FROM source_metadata", [], |row| row.get(0)) + .unwrap(); + assert_ne!(selected, 1); + assert_eq!( + index + .search("old", 5, false, &Arc::new(AtomicBool::new(false)), false) + .unwrap() + .directory_count, + 0 + ); + assert_eq!(index.bind_volume(&evidence).unwrap(), VolumeChange::Keep); + assert_eq!(index.bind_volume(&VolumeEvidence::default()).unwrap(), VolumeChange::Keep); + assert_eq!( + index + .writer + .query_row("SELECT selected_generation FROM source_metadata", [], |row| row.get::<_, i64>(0)) + .unwrap(), + selected + ); + drop(index); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &identity).unwrap(); + evidence.volume = Some("other".into()); + assert_eq!(index.bind_volume(&evidence).unwrap(), VolumeChange::Index); + assert_eq!( + index + .writer + .query_row("SELECT selected_generation FROM source_metadata", [], |row| row.get::<_, i64>(0)) + .unwrap(), + selected + ); + } + + #[test] + fn interrupted_candidate_retains_completed_parent_progress() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("progress.sqlite3"); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &serde_json::json!({"root": "fixture"})).unwrap(); + index.write_paths(["accepted".to_owned()], None).unwrap(); + index.finish_generation(1, 0).unwrap(); + let (candidate, selected) = index.prepare_scan().unwrap(); + assert!(!selected); + index.seed_scan(candidate).unwrap(); + index.write_scan_children(candidate, [("retained".to_owned(), true)]).unwrap(); + index.finish_scan_parent(candidate, "").unwrap(); + assert!(index.has_resumable_scan().unwrap()); + assert_eq!(index.prepare_scan().unwrap(), (candidate, false)); + assert_eq!(index.pending_scan_paths(candidate, 1).unwrap(), ["retained"]); + } + + #[test] + fn completed_scan_revalidation_is_bounded_and_preserves_rows() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("revalidation.sqlite3"); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &serde_json::json!({"root": "fixture"})).unwrap(); + index.write_paths(["accepted".to_owned()], None).unwrap(); + index.finish_generation(1, 0).unwrap(); + let (candidate, selected) = index.prepare_scan().unwrap(); + assert!(!selected); + index.seed_scan(candidate).unwrap(); + let paths: Vec<_> = (0..=ROW_BATCH_SIZE).map(|number| format!("child-{number}")).collect(); + for batch in paths.chunks(ROW_BATCH_SIZE) { + index + .write_scan_children(candidate, batch.iter().map(|path| (path.clone(), true))) + .unwrap(); + } + index.finish_scan_parent(candidate, "").unwrap(); + for path in &paths { + index.finish_scan_parent(candidate, path).unwrap(); + } + let cancelled = Arc::new(AtomicBool::new(false)); + let before = index.search("accepted", 5, false, &cancelled, false).unwrap(); + assert!(index.requeue_completed_scan_batch(candidate).unwrap()); + assert_eq!(index.pending_scan_paths(candidate, ROW_BATCH_SIZE).unwrap().len(), ROW_BATCH_SIZE); + assert!(!index.requeue_completed_scan_batch(candidate).unwrap()); + let pending: i64 = index + .writer + .query_row( + "SELECT count(*) FROM scan_queue WHERE generation_id = ? AND processed = 0", + [candidate], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(pending, checked_sql_size(paths.len() + 1).unwrap()); + assert!(!index.requeue_completed_scan_batch(candidate).unwrap()); + assert!(index.requeue_completed_scan_batch(1).is_err()); + let after = index.search("accepted", 5, false, &cancelled, false).unwrap(); + assert_eq!(before.accepted_revision, after.accepted_revision); + assert_eq!(before.last_verified_at_ms, after.last_verified_at_ms); + assert_eq!(after.directory_count, 1); + assert_eq!(after.results[0].path, "accepted"); + } + + #[test] + fn sqlite_log_fields_exclude_private_error_text() { + for (code, expected) in [ + (rusqlite::ffi::SQLITE_BUSY, "directory_storage_busy"), + (rusqlite::ffi::SQLITE_INTERRUPT, "directory_query_cancelled"), + (rusqlite::ffi::SQLITE_IOERR_WRITE, "directory_transaction_failed"), + ] { + let error = rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error::new(code), + Some("private/path?grant=secret search-term".into()), + ); + let fields = sqlite_failure_fields(&error); + assert_eq!(fields, (expected, code)); + assert!(!format!("event={} sqlite_code={}", fields.0, fields.1).contains("private")); + } + } + + #[test] + fn recovery_replaces_only_invalid_disposable_catalogues() { + for invalid in [ + "PRAGMA user_version = 2", + "UPDATE generations SET indexed_count = indexed_count + 1", + "DELETE FROM source_metadata", + "DROP TABLE scan_queue", + "PRAGMA ignore_check_constraints = ON; UPDATE source_metadata SET normalization_version = 2", + "PRAGMA ignore_check_constraints = ON; UPDATE source_metadata SET traversal_version = 2", + "not_database", + ] { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let identity = json!({"root": "fixture"}); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &identity).unwrap(); + index.write_paths(["rejected".to_owned()], None).unwrap(); + index.finish_generation(1, 0).unwrap(); + if invalid != "not_database" { + index.writer.execute_batch(invalid).unwrap(); + } + drop(index); + if invalid == "not_database" { + fs::write(&path, b"not a SQLite catalogue").unwrap(); + } + let before = fs::read(&path).unwrap(); + let attempted = AtomicBool::new(false); + let mut index = DirectoryIndex::open_with_recovery(&path, SourceKind::Local, &identity, Some(&attempted)).unwrap(); + let response = index + .search("rejected", 3, false, &Arc::new(AtomicBool::new(false)), false) + .unwrap(); + assert_eq!(response.total_matches, 0); + assert_eq!(response.coverage, "partial"); + assert_eq!(response.last_verified_at_ms, None); + assert!(attempted.load(Ordering::Acquire)); + assert_eq!(fs::read(path.parent().unwrap().join(QUARANTINE_FILE_NAME)).unwrap(), before); + assert!(acquire_storage(&path).is_err()); + index.write_paths(["fresh".to_owned()], None).unwrap(); + assert!(index.finish_generation(1, 0).unwrap()); + } + } + + #[test] + fn recovery_refuses_wrong_application_and_source_without_modification() { + for invalid in ["PRAGMA application_id = 1", "UPDATE source_metadata SET source_identity = '{}'"] { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let identity = json!({"root": "fixture"}); + let index = DirectoryIndex::open(&path, SourceKind::Local, &identity).unwrap(); + index.writer.execute_batch(invalid).unwrap(); + drop(index); + let before = fs::read(&path).unwrap(); + let attempted = AtomicBool::new(false); + assert!(DirectoryIndex::open_with_recovery(&path, SourceKind::Local, &identity, Some(&attempted)).is_err()); + assert!(!attempted.load(Ordering::Acquire)); + assert_eq!(fs::read(&path).unwrap(), before); + assert!(!path.parent().unwrap().join(QUARANTINE_FILE_NAME).exists()); + assert!(acquire_storage(&path).is_ok()); + } + } + + #[test] + fn recovery_refuses_second_discard_after_quarantine_failure() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let identity = json!({"root": "fixture"}); + let index = DirectoryIndex::open(&path, SourceKind::Local, &identity).unwrap(); + index.writer.execute_batch("PRAGMA user_version = 2").unwrap(); + drop(index); + let before = fs::read(&path).unwrap(); + let rejected = path.parent().unwrap().join(QUARANTINE_FILE_NAME); + let lease = acquire_storage(&rejected).unwrap(); + let attempted = AtomicBool::new(false); + assert!(DirectoryIndex::open_with_recovery(&path, SourceKind::Local, &identity, Some(&attempted)).is_err()); + assert!(attempted.load(Ordering::Acquire)); + assert_eq!(fs::read(&path).unwrap(), before); + drop(lease); + assert!(matches!( + DirectoryIndex::open_with_recovery(&path, SourceKind::Local, &identity, Some(&attempted)), + Err(IndexError::Invalid(message)) if message.contains("already failed") + )); + assert_eq!(fs::read(&path).unwrap(), before); + assert!(!rejected.exists()); + } + + #[test] + fn recovery_preserves_accepted_data_after_writer_contention() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let identity = json!({"root": "fixture"}); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &identity).unwrap(); + index.write_paths(["accepted".to_owned()], None).unwrap(); + index.finish_generation(1, 0).unwrap(); + let cancelled = Arc::new(AtomicBool::new(false)); + let before = index.search("accepted", 3, false, &cancelled, false).unwrap(); + drop(index); + let blocker = Connection::open(&path).unwrap(); + blocker.execute_batch("BEGIN IMMEDIATE").unwrap(); + let attempted = AtomicBool::new(false); + assert!(DirectoryIndex::open_with_recovery(&path, SourceKind::Local, &identity, Some(&attempted)).is_err()); + assert!(!attempted.load(Ordering::Acquire)); + assert!(!path.parent().unwrap().join(QUARANTINE_FILE_NAME).exists()); + blocker.execute_batch("ROLLBACK").unwrap(); + let index = DirectoryIndex::open_with_recovery(&path, SourceKind::Local, &identity, Some(&attempted)).unwrap(); + assert_eq!(index.search("accepted", 3, false, &cancelled, false).unwrap(), before); + } + + #[test] + fn recovery_quarantine_retains_source_ownership_through_moves() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let identity = json!({"root": "fixture"}); + drop(DirectoryIndex::open(&path, SourceKind::Local, &identity).unwrap()); + let before = fs::read(&path).unwrap(); + let lease = acquire_storage(&path).unwrap(); + quarantine_owned(&path, &lease).unwrap(); + assert!(acquire_storage(&path).is_err()); + assert_eq!(fs::read(path.parent().unwrap().join(QUARANTINE_FILE_NAME)).unwrap(), before); + drop(lease); + assert!(acquire_storage(&path).is_ok()); + } + + #[test] + fn subtree_rename_includes_all_signed_row_ids() { + for identifier in [i64::MIN, -1, 0] { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + index.write_paths(["alpha".to_owned(), "alpha/beta".to_owned()], None).unwrap(); + index + .writer + .execute("UPDATE directories SET id = ? WHERE path = 'alpha/beta'", [identifier]) + .unwrap(); + index + .mutate_path(&DirectoryMutation::Rename { + path: "alpha".to_owned(), + new_path: "renamed".to_owned(), + }) + .unwrap(); + let cancelled = Arc::new(AtomicBool::new(false)); + assert_eq!(index.search("alpha", 3, false, &cancelled, false).unwrap().total_matches, 0); + let response = index.search("renamed", 3, false, &cancelled, false).unwrap(); + assert_eq!(response.total_matches, 2); + assert_eq!(response.directory_count, 2); + } + } + + #[test] + fn startup_rejects_inconsistent_counts_and_unpublished_selection() { + for corruption in [ + "UPDATE generations SET indexed_count = 1 WHERE id = 1", + "UPDATE generations SET issue_count = 1 WHERE id = 1", + "UPDATE generations SET state = 'candidate' WHERE id = 1", + "UPDATE source_metadata SET selected_generation = NULL WHERE id = 1", + ] { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let identity = json!({"root": "fixture"}); + let index = DirectoryIndex::open(&path, SourceKind::Local, &identity).unwrap(); + index.writer.execute_batch(corruption).unwrap(); + drop(index); + assert!( + matches!(DirectoryIndex::open(&path, SourceKind::Local, &identity), Err(IndexError::RebuildRequired(message)) if message.contains("generation metadata")) + ); + } + } + + #[cfg(unix)] + #[test] + fn hard_linked_database_and_sidecars_are_rejected() { + for suffix in ["", ".lease", "-wal", "-shm"] { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let identity = json!({"root": "fixture"}); + let index = DirectoryIndex::open(&path, SourceKind::Local, &identity).unwrap(); + let mut file = path.as_os_str().to_owned(); + file.push(suffix); + let alias = path.parent().unwrap().join("alias.data"); + fs::hard_link(Path::new(&file), &alias).unwrap(); + assert!( + matches!(DirectoryIndex::open(&path, SourceKind::Local, &identity), Err(IndexError::Invalid(message)) if message.contains("hard links")) + ); + assert!(matches!( + index.search("", 3, false, &Arc::new(AtomicBool::new(false)), false), + Err(IndexError::Invalid(_)) + )); + fs::remove_file(alias).unwrap(); + assert_eq!( + index + .search("", 3, false, &Arc::new(AtomicBool::new(false)), false) + .unwrap() + .directory_count, + 0 + ); + } + } + + #[cfg(unix)] + #[test] + fn held_storage_directory_refuses_replaced_namespace_without_mutating_other_files() { + for replacement in ["link", "directory"] { + let temporary = tempfile::tempdir().unwrap(); + let namespace = temporary.path().join("private"); + let path = namespace.join("source.sqlite3"); + drop(DirectoryIndex::open(&path, SourceKind::Local, &json!({"root": "fixture"})).unwrap()); + let directory = StorageDirectory::open(&namespace).unwrap(); + let original = temporary.path().join("original"); + let unrelated = temporary.path().join("unrelated"); + fs::create_dir(&unrelated).unwrap(); + fs::set_permissions(&unrelated, fs::Permissions::from_mode(PRIVATE_DIRECTORY_MODE)).unwrap(); + let victim = unrelated.join("source.sqlite3"); + fs::write(&victim, b"unrelated data").unwrap(); + fs::rename(&namespace, &original).unwrap(); + if replacement == "link" { + std::os::unix::fs::symlink(&unrelated, &namespace).unwrap(); + } else { + fs::rename(&unrelated, &namespace).unwrap(); + } + assert!(directory.remove(&path).is_err()); + assert!(directory.rename(&path, &namespace.join(QUARANTINE_FILE_NAME)).is_err()); + assert!(original.join("source.sqlite3").exists()); + assert_eq!(fs::read(&path).unwrap(), b"unrelated data"); + if replacement == "link" { + fs::remove_file(&namespace).unwrap(); + } else { + fs::rename(&namespace, &unrelated).unwrap(); + } + fs::rename(&original, &namespace).unwrap(); + directory.rename(&path, &namespace.join("renamed.sqlite3")).unwrap(); + directory.remove(&namespace.join("renamed.sqlite3")).unwrap(); + assert_eq!(fs::read(&victim).unwrap(), b"unrelated data"); + } + } + + #[cfg(unix)] + #[test] + fn readonly_queries_revalidate_private_namespace_files_and_ancestors() { + for changed in ["namespace", "database", "ancestor"] { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let identity = json!({"root": "fixture"}); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &identity).unwrap(); + index.write_paths(["retained".into()], None).unwrap(); + assert!(index.finish_generation(1, 0).unwrap()); + let cancelled = Arc::new(AtomicBool::new(false)); + let before = index.search("retained", 3, false, &cancelled, false).unwrap(); + let (target, unsafe_mode) = match changed { + "namespace" => (path.parent().unwrap(), 0o755), + "database" => (path.as_path(), 0o620), + _ => (temporary.path(), 0o770), + }; + let permissions = fs::metadata(target).unwrap().permissions(); + fs::set_permissions(target, fs::Permissions::from_mode(unsafe_mode)).unwrap(); + let refused = index.search("retained", 3, false, &cancelled, false); + fs::set_permissions(target, permissions).unwrap(); + assert!(matches!(refused, Err(IndexError::Invalid(_)))); + assert_eq!(index.search("retained", 3, false, &cancelled, false).unwrap(), before); + } + } + + #[cfg(windows)] + #[test] + fn windows_readers_reject_database_family_hard_links_and_recover_after_removal() { + for suffix in ["", ".lease", "-wal", "-shm"] { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let identity = json!({"root": "fixture"}); + let index = DirectoryIndex::open(&path, SourceKind::Local, &identity).unwrap(); + let alias = path.parent().unwrap().join("\u{e9}-alias.data"); + fs::hard_link(storage_member(&path, suffix), &alias).unwrap(); + assert!(matches!( + index.search("", 3, false, &Arc::new(AtomicBool::new(false)), false), + Err(IndexError::Invalid(_)) + )); + drop(index); + fs::remove_file(alias).unwrap(); + let reopened = DirectoryIndex::open(&path, SourceKind::Local, &identity).unwrap(); + assert_eq!( + reopened + .search("", 3, false, &Arc::new(AtomicBool::new(false)), false) + .unwrap() + .directory_count, + 0 + ); + } + } + + #[test] + fn namespace_lease_serializes_source_lease_creation() { + let temporary = tempfile::tempdir().unwrap(); + let namespace = temporary.path().join("private"); + let path = namespace.join("source.sqlite3"); + let lease = acquire_namespace_lease(&namespace).unwrap(); + assert!( + matches!(DirectoryIndex::open(&path, SourceKind::Local, &json!({"id": "fixture"})), Err(IndexError::Io(error)) if error.kind() == std::io::ErrorKind::WouldBlock) + ); + drop(lease); + let index = DirectoryIndex::open(&path, SourceKind::Local, &json!({"id": "fixture"})).unwrap(); + assert_eq!( + index + .search("", 3, false, &Arc::new(AtomicBool::new(false)), false) + .unwrap() + .directory_count, + 0 + ); + } + + #[test] + fn normal_checkpoint_defers_for_pinned_reader_without_changing_results() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + index.writer.execute_batch("PRAGMA wal_autocheckpoint = 0").unwrap(); + let reader = Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_ONLY).unwrap(); + reader.execute_batch("BEGIN; SELECT count(*) FROM directories;").unwrap(); + index + .write_paths( + (0..ROW_BATCH_SIZE * 4).map(|number| format!("match/{number}/{}", "long-name-".repeat(100))), + None, + ) + .unwrap(); + let cancelled = Arc::new(AtomicBool::new(false)); + let before = index.search("match", 3, false, &cancelled, false).unwrap(); + let mut wal = path.as_os_str().to_owned(); + wal.push("-wal"); + let wal = PathBuf::from(wal); + assert!(fs::metadata(&wal).unwrap().len() >= CHECKPOINT_WAL_BYTES); + assert!(!index.checkpoint_if_due().unwrap()); + assert!(fs::metadata(&wal).unwrap().len() >= CHECKPOINT_WAL_BYTES); + reader.execute_batch("ROLLBACK").unwrap(); + assert!(index.checkpoint_if_due().unwrap()); + assert_eq!(fs::metadata(&wal).unwrap().len(), 0); + assert_eq!(index.search("match", 3, false, &cancelled, false).unwrap(), before); + } + + #[test] + fn fts_repair_is_bounded_restartable_and_preserves_rows() { + for missing_table in [false, true] { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let identity = serde_json::json!({"root": "fixture"}); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &identity).unwrap(); + index + .write_paths((0..ROW_BATCH_SIZE + 1).map(|number| format!("match/{number}")), None) + .unwrap(); + index.finish_generation(1, 0).unwrap(); + let cancelled = Arc::new(AtomicBool::new(false)); + let mut before = index.search("match", 3, false, &cancelled, false).unwrap(); + index + .writer + .execute_batch(if missing_table { + "DROP TABLE directory_fts" + } else { + "INSERT INTO directory_fts(directory_fts) VALUES ('delete-all')" + }) + .unwrap(); + drop(index); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &identity).unwrap(); + let reader = index.reader(); + assert!(index.needs_fts_repair()); + before.query_plan = "scan"; + assert_eq!(reader.search("match", 3, false, &cancelled, false).unwrap(), before); + assert!(index + .mutate_path(&DirectoryMutation::Remove { + path: "match/0".to_owned() + }) + .is_err()); + assert!(index.repair_fts_batch().unwrap()); + let indexed: i64 = index + .writer + .query_row("SELECT count(*) FROM directory_fts_docsize", [], |row| row.get(0)) + .unwrap(); + assert_eq!(indexed, ROW_BATCH_SIZE as i64); + drop(index); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &identity).unwrap(); + let reader = index.reader(); + assert!(index.repair_fts_batch().unwrap()); + assert!(!index.repair_fts_batch().unwrap()); + assert!(!index.needs_fts_repair()); + before.query_plan = "trigram"; + assert_eq!(reader.search("match", 3, false, &cancelled, false).unwrap(), before); + index + .mutate_path(&DirectoryMutation::Add { + path: "match/extra".to_owned(), + }) + .unwrap(); + assert_eq!( + index.search("match", 3, false, &cancelled, false).unwrap().total_matches, + before.total_matches + 1 + ); + } + } + + #[test] + fn interrupted_partial_fts_repair_preserves_rows_cursor_and_retry() { + use std::sync::atomic::AtomicUsize; + + const INTERRUPT_AFTER_STEPS: usize = 4096; + + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let identity = json!({"root": "fixture"}); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &identity).unwrap(); + index + .write_paths((0..ROW_BATCH_SIZE + 1).map(|number| format!("accepted/{number}")), None) + .unwrap(); + assert!(index.finish_generation(1, 0).unwrap()); + index + .writer + .execute_batch("INSERT INTO directory_fts(directory_fts) VALUES ('delete-all')") + .unwrap(); + drop(index); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &identity).unwrap(); + let cancelled = Arc::new(AtomicBool::new(false)); + let mut before = index.search("accepted", 3, false, &cancelled, false).unwrap(); + assert!(index.needs_fts_repair()); + let cursor = index.fts_repair_cursor; + let changes_before: i64 = index.writer.query_row("SELECT total_changes()", [], |row| row.get(0)).unwrap(); + let steps = Arc::new(AtomicUsize::new(0)); + let observed = Arc::clone(&steps); + index + .writer + .progress_handler(1, Some(move || observed.fetch_add(1, Ordering::Relaxed) >= INTERRUPT_AFTER_STEPS)) + .unwrap(); + let outcome = index.repair_fts_batch(); + index.writer.progress_handler(0, None:: bool>).unwrap(); + assert!(outcome.is_err()); + assert!(steps.load(Ordering::Relaxed) > INTERRUPT_AFTER_STEPS); + let changes_after: i64 = index.writer.query_row("SELECT total_changes()", [], |row| row.get(0)).unwrap(); + assert!( + changes_after > changes_before, + "The interruption must occur after a partial token write." + ); + assert_eq!(index.fts_repair_cursor, cursor); + assert!(index.needs_fts_repair()); + let tokens: i64 = index + .writer + .query_row("SELECT count(*) FROM directory_fts_docsize", [], |row| row.get(0)) + .unwrap(); + assert_eq!(tokens, 0); + assert_eq!(index.search("accepted", 3, false, &cancelled, false).unwrap(), before); + assert!(index.repair_fts_batch().unwrap()); + assert!(!index.repair_fts_batch().unwrap()); + assert!(!index.needs_fts_repair()); + before.query_plan = "trigram"; + assert_eq!(index.search("accepted", 3, false, &cancelled, false).unwrap(), before); + } + + #[test] + fn full_fts_repair_preserves_rows_cursor_and_retry() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let identity = json!({"root": "fixture"}); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &identity).unwrap(); + index + .write_paths((0..ROW_BATCH_SIZE + 1).map(|number| format!("accepted/{number}")), None) + .unwrap(); + assert!(index.finish_generation(1, 0).unwrap()); + index + .writer + .execute_batch("INSERT INTO directory_fts(directory_fts) VALUES ('delete-all')") + .unwrap(); + drop(index); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &identity).unwrap(); + let cancelled = Arc::new(AtomicBool::new(false)); + let mut before = index.search("accepted", 3, false, &cancelled, false).unwrap(); + let cursor = index.fts_repair_cursor; + let page_size: i64 = index.writer.query_row("PRAGMA page_size", [], |row| row.get(0)).unwrap(); + let free_pages: i64 = index.writer.query_row("PRAGMA freelist_count", [], |row| row.get(0)).unwrap(); + index.writer.execute_batch("CREATE TABLE pressure(payload BLOB)").unwrap(); + index + .writer + .execute("INSERT INTO pressure VALUES (zeroblob(?))", [(free_pages + 1) * page_size]) + .unwrap(); + let original_limit: i64 = index.writer.query_row("PRAGMA max_page_count", [], |row| row.get(0)).unwrap(); + let pages: i64 = index.writer.query_row("PRAGMA page_count", [], |row| row.get(0)).unwrap(); + index.writer.pragma_update(None, "max_page_count", pages).unwrap(); + assert!(matches!( + index.repair_fts_batch(), + Err(IndexError::Sql(rusqlite::Error::SqliteFailure(code, _))) if code.code == rusqlite::ErrorCode::DiskFull + )); + assert_eq!(index.fts_repair_cursor, cursor); + assert!(index.needs_fts_repair()); + let tokens: i64 = index + .writer + .query_row("SELECT count(*) FROM directory_fts_docsize", [], |row| row.get(0)) + .unwrap(); + assert_eq!(tokens, 0); + assert_eq!(index.search("accepted", 3, false, &cancelled, false).unwrap(), before); + index.writer.pragma_update(None, "max_page_count", original_limit).unwrap(); + index.writer.execute_batch("DROP TABLE pressure").unwrap(); + assert!(index.repair_fts_batch().unwrap()); + assert!(!index.repair_fts_batch().unwrap()); + assert!(!index.needs_fts_repair()); + before.query_plan = "trigram"; + assert_eq!(index.search("accepted", 3, false, &cancelled, false).unwrap(), before); + } + + #[test] + fn ioerr_during_partial_fts_repair_preserves_rows_cursor_and_retry() { + unsafe extern "C" fn fail_write( + context: *mut rusqlite::ffi::sqlite3_context, + _argument_count: std::ffi::c_int, + _arguments: *mut *mut rusqlite::ffi::sqlite3_value, + ) { + // SAFETY: SQLite supplies a live context for this registered callback. + unsafe { rusqlite::ffi::sqlite3_result_error_code(context, rusqlite::ffi::SQLITE_IOERR_WRITE) }; + } + + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let identity = json!({"root": "fixture"}); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &identity).unwrap(); + index + .write_paths((0..ROW_BATCH_SIZE + 1).map(|number| format!("accepted/{number}")), None) + .unwrap(); + assert!(index.finish_generation(1, 0).unwrap()); + index + .writer + .execute_batch("INSERT INTO directory_fts(directory_fts) VALUES ('delete-all')") + .unwrap(); + drop(index); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &identity).unwrap(); + let cancelled = Arc::new(AtomicBool::new(false)); + let mut before = index.search("accepted", 3, false, &cancelled, false).unwrap(); + let cursor = index.fts_repair_cursor; + let changes_before: i64 = index.writer.query_row("SELECT total_changes()", [], |row| row.get(0)).unwrap(); + // SAFETY: The connection is live, the name is static, and the callback has no user data or destructor. + let registered = unsafe { + rusqlite::ffi::sqlite3_create_function_v2( + index.writer.handle(), + c"test_directory_ioerr".as_ptr(), + 0, + rusqlite::ffi::SQLITE_UTF8 | rusqlite::ffi::SQLITE_INNOCUOUS, + std::ptr::null_mut(), + Some(fail_write), + None, + None, + None, + ) + }; + assert_eq!(registered, rusqlite::ffi::SQLITE_OK); + index + .writer + .execute_batch( + "CREATE TEMP TRIGGER fail_fts_write BEFORE INSERT ON directory_fts_docsize + WHEN (SELECT count(*) FROM directory_fts_docsize) > 0 + BEGIN SELECT test_directory_ioerr(); END;", + ) + .unwrap(); + assert!(matches!( + index.repair_fts_batch(), + Err(IndexError::Sql(rusqlite::Error::SqliteFailure(code, _))) + if code.extended_code == rusqlite::ffi::SQLITE_IOERR_WRITE + )); + let changes_after: i64 = index.writer.query_row("SELECT total_changes()", [], |row| row.get(0)).unwrap(); + assert!( + changes_after > changes_before, + "The I/O error must occur after a partial token write." + ); + assert_eq!(index.fts_repair_cursor, cursor); + assert!(index.needs_fts_repair()); + let tokens: i64 = index + .writer + .query_row("SELECT count(*) FROM directory_fts_docsize", [], |row| row.get(0)) + .unwrap(); + assert_eq!(tokens, 0); + assert_eq!(index.search("accepted", 3, false, &cancelled, false).unwrap(), before); + index.writer.execute_batch("DROP TRIGGER fail_fts_write").unwrap(); + assert!(index.repair_fts_batch().unwrap()); + assert!(!index.repair_fts_batch().unwrap()); + assert!(!index.needs_fts_repair()); + before.query_plan = "trigram"; + assert_eq!(index.search("accepted", 3, false, &cancelled, false).unwrap(), before); + } + + #[test] + fn refused_fts_reset_does_not_replace_rows_and_retries_after_reopen() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let identity = json!({"root": "fixture"}); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &identity).unwrap(); + index + .write_paths(["accepted/first".into(), "accepted/second".into()], None) + .unwrap(); + assert!(index.finish_generation(1, 0).unwrap()); + let cancelled = Arc::new(AtomicBool::new(false)); + let before = index.search("accepted", 3, false, &cancelled, true).unwrap(); + index + .writer + .execute_batch( + "INSERT INTO directory_fts(directory_fts) VALUES ('delete-all'); + CREATE TRIGGER refuse_fts_reset BEFORE DELETE ON directory_fts_data + BEGIN SELECT RAISE(ABORT, 'Injected FTS reset refusal.'); END;", + ) + .unwrap(); + drop(index); + assert!(matches!( + DirectoryIndex::open(&path, SourceKind::Local, &identity), + Err(IndexError::Sql(rusqlite::Error::SqliteFailure(code, _))) + if code.code == rusqlite::ErrorCode::ConstraintViolation + )); + assert!(path.is_file()); + let connection = Connection::open(&path).unwrap(); + let retained: i64 = connection + .query_row("SELECT count(*) FROM directories", [], |row| row.get(0)) + .unwrap(); + assert_eq!(retained, before.directory_count); + connection.execute_batch("DROP TRIGGER refuse_fts_reset").unwrap(); + drop(connection); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &identity).unwrap(); + assert!(index.needs_fts_repair()); + assert_eq!(index.search("accepted", 3, false, &cancelled, false).unwrap(), before); + assert!(!index.repair_fts_batch().unwrap()); + assert!(!index.needs_fts_repair()); + assert_eq!(index.search("accepted", 3, false, &cancelled, true).unwrap(), before); + let mut repaired = before; + repaired.query_plan = "trigram"; + assert_eq!(index.search("accepted", 3, false, &cancelled, false).unwrap(), repaired); + drop(index); + let reopened = DirectoryIndex::open(&path, SourceKind::Local, &identity).unwrap(); + assert!(!reopened.needs_fts_repair()); + assert_eq!(reopened.search("accepted", 3, false, &cancelled, false).unwrap(), repaired); + } + + #[test] + fn incremental_reclamation_preserves_results_and_verification() { + let temporary = tempfile::tempdir().unwrap(); + let mut index = DirectoryIndex::open( + &temporary.path().join("private").join("source.sqlite3"), + SourceKind::Local, + &serde_json::json!({"root": "test"}), + ) + .unwrap(); + index + .write_paths( + (0..ROW_BATCH_SIZE * 2).map(|number| format!("old/{number}/{}", "long-name-".repeat(100))), + None, + ) + .unwrap(); + index.finish_generation(1, 0).unwrap(); + let candidate = index.begin_verification().unwrap(); + index.write_paths(["selected".to_owned()], Some(candidate)).unwrap(); + index.finish_generation(candidate, 0).unwrap(); + while index.cleanup_retired_batch().unwrap() {} + let before = index + .search("selected", 3, false, &Arc::new(AtomicBool::new(false)), false) + .unwrap(); + let pages_before: i64 = index.writer.query_row("PRAGMA page_count", [], |row| row.get(0)).unwrap(); + let free: i64 = index.writer.query_row("PRAGMA freelist_count", [], |row| row.get(0)).unwrap(); + assert!(free > 0); + let reader = Connection::open_with_flags(&index.path, OpenFlags::SQLITE_OPEN_READ_ONLY).unwrap(); + reader.execute_batch("BEGIN; SELECT count(*) FROM directories;").unwrap(); + index.touch_access(1).unwrap(); + assert!(!index.reclaim_pages().unwrap()); + let pinned_pages: i64 = index.writer.query_row("PRAGMA page_count", [], |row| row.get(0)).unwrap(); + assert_eq!(pinned_pages, pages_before); + reader.execute_batch("ROLLBACK").unwrap(); + assert!(index.reclaim_pages().unwrap()); + let pages_after: i64 = index.writer.query_row("PRAGMA page_count", [], |row| row.get(0)).unwrap(); + assert!(pages_after < pages_before); + assert_eq!( + index + .search("selected", 3, false, &Arc::new(AtomicBool::new(false)), false) + .unwrap(), + before + ); + } + + #[test] + fn retired_cleanup_is_bounded_and_preserves_selected_metadata() { + let directory = tempfile::tempdir().unwrap(); + let mut index = DirectoryIndex::open( + &directory.path().join("private").join("cleanup.sqlite3"), + SourceKind::Local, + &serde_json::json!({"root": "test"}), + ) + .unwrap(); + index + .write_paths((0..ROW_BATCH_SIZE * 2).map(|number| format!("old/{number}")), None) + .unwrap(); + index.finish_generation(1, 0).unwrap(); + let candidate = index.begin_verification().unwrap(); + index.write_paths(["selected".to_owned()], Some(candidate)).unwrap(); + index.finish_generation(candidate, 0).unwrap(); + let cancelled = Arc::new(AtomicBool::new(false)); + let before = index.search("selected", 3, false, &cancelled, false).unwrap(); + assert!(index.cleanup_retired_batch().unwrap()); + let remaining: i64 = index + .writer + .query_row("SELECT indexed_count FROM generations WHERE id = 1", [], |row| row.get(0)) + .unwrap(); + assert_eq!(usize::try_from(remaining).unwrap(), ROW_BATCH_SIZE); + assert!(!index.cleanup_retired_batch().unwrap()); + assert_eq!(index.search("selected", 3, false, &cancelled, false).unwrap(), before); + index + .writer + .execute("INSERT INTO directory_fts(directory_fts, rank) VALUES ('integrity-check', 1)", []) + .unwrap(); + } + + use super::*; + use serde_json::json; + + const FIXTURES: &str = include_str!("../../../../shared/directory-index/search-fixtures.json"); + + #[test] + fn shared_fixtures_match_scan_and_trigram() { + let fixtures: serde_json::Value = serde_json::from_str(FIXTURES).unwrap(); + assert!(search_terms(&"a".repeat(MAX_QUERY_BYTES)).is_ok()); + assert!(search_terms(&"😀".repeat(MAX_QUERY_BYTES / 4)).is_ok()); + assert!(search_terms(&"😀".repeat(MAX_QUERY_BYTES / 4 + 1)).is_err()); + let terms = (0..MAX_QUERY_TERMS).map(|number| format!("term{number}")).collect::>(); + assert_eq!(search_terms(&terms.join(" ")).unwrap().len(), MAX_QUERY_TERMS); + assert!(search_terms(&format!("{} excess", terms.join(" "))).is_err()); + for case in fixtures["normalization"].as_array().unwrap() { + assert_eq!( + normalize_search_text(case["input"].as_str().unwrap()), + case["expected"].as_str().unwrap() + ); + } + for case in fixtures["queries"].as_array().unwrap() { + let mut expected: Vec<_> = case["terms"] + .as_array() + .unwrap() + .iter() + .map(|term| term.as_str().unwrap().to_owned()) + .collect(); + expected.sort(); + assert_eq!(search_terms(case["query"].as_str().unwrap()).unwrap(), expected); + } + let temporary = tempfile::tempdir().unwrap(); + let mut index = DirectoryIndex::open( + &temporary.path().join("private").join("source.sqlite3"), + SourceKind::Local, + &json!({"root": "fixture"}), + ) + .unwrap(); + index + .write_paths( + fixtures["paths"] + .as_array() + .unwrap() + .iter() + .map(|path| path.as_str().unwrap().to_owned()), + None, + ) + .unwrap(); + for case in fixtures["searches"].as_array().unwrap() { + for force_scan in [false, true] { + let response = index + .search( + case["query"].as_str().unwrap(), + MAX_RESULT_LIMIT, + case["include_dot"].as_bool().unwrap(), + &Arc::new(AtomicBool::new(false)), + force_scan, + ) + .unwrap(); + assert_eq!(response.total_matches, case["count"].as_i64().unwrap()); + let expected: Vec<_> = case["results"] + .as_array() + .unwrap() + .iter() + .map(|path| path.as_str().unwrap()) + .collect(); + assert_eq!(response.results.iter().map(|row| row.path.as_str()).collect::>(), expected); + } + } + index + .write_paths( + (0..ROW_BATCH_SIZE) + .map(|number| format!("single/{number}/needleone")) + .chain(std::iter::once("last/needleone-needletwo".to_owned())), + None, + ) + .unwrap(); + for force_scan in [false, true] { + let response = index + .search("needleone needletwo", 1, false, &Arc::new(AtomicBool::new(false)), force_scan) + .unwrap(); + assert_eq!(response.total_matches, ROW_BATCH_SIZE as i64 + 1); + assert_eq!(response.results.len(), 1); + assert_eq!(response.results[0].path, "last/needleone-needletwo"); + assert_eq!(response.results[0].matched_term_count, 2); + } + } + + #[test] + fn pending_scan_reads_do_not_walk_processed_prefix_or_sort_pending_jobs() { + use std::sync::atomic::AtomicUsize; + + let temporary = tempfile::tempdir().unwrap(); + let mut index = DirectoryIndex::open( + &temporary.path().join("private").join("source.sqlite3"), + SourceKind::Local, + &json!({"root": "fixture"}), + ) + .unwrap(); + let transaction = index.writer.transaction().unwrap(); + { + let mut statement = transaction + .prepare("INSERT INTO scan_queue(generation_id, path, processed) VALUES (1, ?, ?)") + .unwrap(); + for number in 0..ROW_BATCH_SIZE * 32 { + statement.execute(params![format!("done-{number:06}"), 1]).unwrap(); + } + for number in 0..ROW_BATCH_SIZE { + statement.execute(params![format!("pending-{number:06}"), 0]).unwrap(); + } + } + transaction.commit().unwrap(); + let steps = Arc::new(AtomicUsize::new(0)); + let observed = Arc::clone(&steps); + index + .writer + .progress_handler( + 1, + Some(move || { + observed.fetch_add(1, Ordering::Relaxed); + false + }), + ) + .unwrap(); + let pending = index.pending_scan_paths(1, 1).unwrap(); + index.writer.progress_handler(0, None:: bool>).unwrap(); + assert_eq!(pending, ["pending-000000"]); + assert!(steps.load(Ordering::Relaxed) < ROW_BATCH_SIZE); + } + + #[test] + fn candidate_failure_selection_reopen_and_source_binding() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let identity = json!({"root": "fixture"}); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &identity).unwrap(); + index.write_paths(["old/alpha".into()], None).unwrap(); + index.finish_generation(1, 0).unwrap(); + let accepted = index.search("alpha", 10, false, &Arc::new(AtomicBool::new(false)), false).unwrap(); + let candidate = index.begin_verification().unwrap(); + index.write_paths(["new/beta".into()], Some(candidate)).unwrap(); + assert!(!index.finish_generation(candidate, 1).unwrap()); + assert_eq!( + index.search("alpha", 10, false, &Arc::new(AtomicBool::new(false)), false).unwrap(), + accepted + ); + assert!(!index.finish_generation(candidate, 0).unwrap()); + index.discard_generation(candidate).unwrap(); + let candidate = index.begin_verification().unwrap(); + index.write_paths(["new/beta".into()], Some(candidate)).unwrap(); + assert!(index.finish_generation(candidate, 0).unwrap()); + assert!(index.write_paths(["late/beta".into()], Some(candidate)).is_err()); + drop(index); + assert!(DirectoryIndex::open(&path, SourceKind::Smb, &identity).is_err()); + let index = DirectoryIndex::open(&path, SourceKind::Local, &identity).unwrap(); + assert_eq!( + index + .search("beta", 10, false, &Arc::new(AtomicBool::new(false)), false) + .unwrap() + .total_matches, + 1 + ); + } + + #[test] + fn count_has_no_discovery_cutoff() { + check_count_and_bounded_results(100_001); + } + + #[test] + fn bulk_path_writes_bound_bytes_before_building_row_keys() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + let suffix = "x".repeat(SCAN_BATCH_BYTES / 2); + index.write_paths((0..3).map(|number| format!("{number}/{suffix}")), None).unwrap(); + let status = index.search("", 3, false, &Arc::new(AtomicBool::new(false)), false).unwrap(); + assert_eq!(status.directory_count, 3); + assert_eq!(status.accepted_revision, 3); + assert!(matches!( + index.write_paths(["x".repeat(SCAN_BATCH_BYTES + 1)], None), + Err(IndexError::Invalid(_)) + )); + assert_eq!( + index.search("", 3, false, &Arc::new(AtomicBool::new(false)), false).unwrap(), + status + ); + } + + #[test] + #[ignore = "Separate million-path capacity check; run explicitly with --ignored."] + fn million_path_capacity() { + check_count_and_bounded_results(1_000_001); + } + + #[cfg(target_os = "linux")] + #[test] + #[ignore = "Explicit small operation-headroom measurement; not part of routine tests."] + fn operation_headroom_measurement() { + fn sample(namespace: &Path, peaks: &std::sync::Mutex<(u64, u64, usize)>) { + let managed = fs::read_dir(namespace) + .unwrap() + .map(|entry| entry.unwrap().metadata().unwrap().len()) + .sum::(); + let descriptors = fs::read_dir("/proc/self/fd").unwrap().collect::, _>>().unwrap(); + let deleted = descriptors + .iter() + .filter_map(|entry| { + let target = fs::read_link(entry.path()).ok()?; + let target = target.to_string_lossy(); + (target.contains("etilqs") && target.ends_with(" (deleted)")) + .then(|| fs::metadata(entry.path()).ok().map(|metadata| metadata.len())) + .flatten() + }) + .sum::(); + let mut peaks = peaks.lock().unwrap(); + peaks.0 = peaks.0.max(managed); + peaks.1 = peaks.1.max(deleted); + peaks.2 = peaks.2.max(descriptors.len()); + } + + let temporary = tempfile::tempdir().unwrap(); + let namespace = temporary.path().join("private"); + let path = namespace.join("source.sqlite3"); + let second_path = namespace.join("second.sqlite3"); + let identity = json!({"root": "fixture"}); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &identity).unwrap(); + let mut second = DirectoryIndex::open(&second_path, SourceKind::Local, &identity).unwrap(); + let peaks = Arc::new(std::sync::Mutex::new((0, 0, 0))); + for writer in [&mut index.writer, &mut second.writer] { + let namespace = namespace.clone(); + let peaks = Arc::clone(&peaks); + writer + .progress_handler( + SQL_PROGRESS_STEPS, + Some(move || { + sample(&namespace, &peaks); + false + }), + ) + .unwrap(); + } + let paths = (0..4096) + .map(|number| format!("operation/{number:04}/repeated-token-{number:04}")) + .collect::>(); + index.write_paths(paths.iter().cloned(), None).unwrap(); + assert!(index.finish_generation(1, 0).unwrap()); + second.write_paths(paths.iter().cloned(), None).unwrap(); + assert!(second.finish_generation(1, 0).unwrap()); + let reader = open_readonly_directory_database(&path).unwrap(); + reader.execute_batch("BEGIN").unwrap(); + let old_count: i64 = reader.query_row("SELECT COUNT(*) FROM directories", [], |row| row.get(0)).unwrap(); + let candidate = index.begin_verification().unwrap(); + index.write_paths(paths.iter().cloned(), Some(candidate)).unwrap(); + index.write_paths(["operation/new".to_owned()], None).unwrap(); + assert_eq!( + reader + .query_row("SELECT COUNT(*) FROM directories", [], |row| row.get::<_, i64>(0)) + .unwrap(), + old_count + ); + index.checkpoint_if_due().unwrap(); + let cancelled = Arc::new(AtomicBool::new(false)); + let before = index.search("operation repeated", 3, false, &cancelled, false).unwrap(); + index + .writer + .execute("INSERT INTO directory_fts(directory_fts) VALUES ('delete-all')", []) + .unwrap(); + index.fts_ready.store(false, Ordering::Release); + index.fts_repair_cursor = None; + while index.repair_fts_batch().unwrap() { + sample(&namespace, &peaks); + } + assert_eq!(index.search("operation repeated", 3, false, &cancelled, false).unwrap(), before); + reader.execute_batch("ROLLBACK").unwrap(); + drop(reader); + assert!(index.finish_generation(candidate, 0).unwrap()); + while index.cleanup_retired_batch().unwrap() { + sample(&namespace, &peaks); + } + index.reclaim_pages().unwrap(); + drop(second); + let lease = acquire_storage(&second_path).unwrap(); + quarantine_owned(&second_path, &lease).unwrap(); + sample(&namespace, &peaks); + let peaks = peaks.lock().unwrap(); + eprintln!( + "DIRECTORY_INDEX_HEADROOM_MEASUREMENT rows_per_source=4096 managed_bytes={} sql_deleted_bytes={} descriptors={}", + peaks.0, peaks.1, peaks.2 + ); + } + + fn check_count_and_bounded_results(count: i64) { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let identity = json!({"root": "fixture"}); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &identity).unwrap(); + index + .write_paths((0..count).map(|number| format!("directory-{number:06}")), None) + .unwrap(); + let managed_bytes: u64 = std::fs::read_dir(path.parent().unwrap()) + .unwrap() + .map(|entry| entry.unwrap().metadata().unwrap().len()) + .sum(); + eprintln!("directory_index_fixture rows={count} managed_bytes={managed_bytes}"); + for query in ["directory", "ry"] { + let response = index.search(query, 3, false, &Arc::new(AtomicBool::new(false)), false).unwrap(); + assert_eq!(response.total_matches, count); + assert_eq!(response.directory_count, count); + assert_eq!(response.results.len(), 3); + } + assert!(index.finish_generation(1, 0).unwrap()); + let cancelled = Arc::new(AtomicBool::new(false)); + let verified = index.search("", 3, false, &cancelled, false).unwrap().last_verified_at_ms; + let candidate = index.begin_verification().unwrap(); + index + .write_paths((0..count).map(|number| format!("directory-{number:06}")), Some(candidate)) + .unwrap(); + assert_eq!( + index.search("directory", 3, false, &cancelled, false).unwrap().directory_count, + count + ); + index + .mutate_path(&DirectoryMutation::Rename { + path: "directory-100000".into(), + new_path: "directory-updated".into(), + }) + .unwrap(); + assert_eq!(index.search("100000", 3, false, &cancelled, false).unwrap().total_matches, 0); + assert_eq!( + index.search("updated", 3, false, &cancelled, false).unwrap().results[0].path, + "directory-updated" + ); + assert_eq!(index.search("", 3, false, &cancelled, false).unwrap().last_verified_at_ms, verified); + assert!(index.finish_generation(candidate, 0).unwrap()); + drop(index); + let index = DirectoryIndex::open(&path, SourceKind::Local, &identity).unwrap(); + let response = index.search("directory", 3, false, &cancelled, false).unwrap(); + assert_eq!(response.directory_count, count); + assert_eq!(response.total_matches, count); + assert_eq!(index.search("100000", 3, false, &cancelled, false).unwrap().total_matches, 0); + assert_eq!( + index.search("updated", 3, false, &cancelled, false).unwrap().results[0].path, + "directory-updated" + ); + } + + #[test] + fn schema_rollback_and_fts_cleanup() { + let mut database = Connection::open_in_memory().unwrap(); + database.execute_batch(SCHEMA).unwrap(); + database.execute_batch(FTS_SCHEMA).unwrap(); + database + .execute("INSERT INTO generations(id, state, coverage) VALUES (1, 'building', 'partial')", []) + .unwrap(); + let transaction = database.transaction().unwrap(); + transaction.execute("INSERT INTO directories(generation_id, path, path_key, basename_key, depth, has_dot_directory) VALUES (1, 'alpha', 'alpha', 'alpha', 0, 0)", []).unwrap(); + transaction.rollback().unwrap(); + assert_eq!( + database + .query_row::("SELECT indexed_count FROM generations", [], |row| row.get(0)) + .unwrap(), + 0 + ); + database.execute("INSERT INTO directories(generation_id, path, path_key, basename_key, depth, has_dot_directory) VALUES (1, 'alpha', 'alpha', 'alpha', 0, 0)", []).unwrap(); + database.execute("DELETE FROM generations WHERE id = 1", []).unwrap(); + assert_eq!( + database + .query_row::("SELECT count(*) FROM directory_fts WHERE directory_fts MATCH 'alpha'", [], |row| { + row.get(0) + }) + .unwrap(), + 0 + ); + } + + #[test] + fn literal_queries_colliding_keys_cancellation_and_limits() { + let temporary = tempfile::tempdir().unwrap(); + let mut index = DirectoryIndex::open( + &temporary.path().join("private").join("source.sqlite3"), + SourceKind::Local, + &json!({"root": "fixture"}), + ) + .unwrap(); + index + .write_paths(["a%/_*/\"quoted\"".into(), "Straße".into(), "STRASSE".into()], None) + .unwrap(); + assert_eq!( + index + .search("a% _* \"quoted\"", 10, false, &Arc::new(AtomicBool::new(false)), false) + .unwrap() + .total_matches, + 1 + ); + assert_eq!( + index + .search("strasse", 10, false, &Arc::new(AtomicBool::new(false)), false) + .unwrap() + .total_matches, + 2 + ); + assert!(matches!( + index.search("strasse", 10, false, &Arc::new(AtomicBool::new(true)), false), + Err(IndexError::Interrupted) + )); + assert!(search_terms(&"x".repeat(MAX_QUERY_BYTES + 1)).is_err()); + assert!(search_terms( + &(0..=MAX_QUERY_TERMS) + .map(|number| format!("term{number}")) + .collect::>() + .join(" ") + ) + .is_err()); + for path in ["", "/alpha", "alpha/", "a//b", "a/./b", "a/../b", "a\0b"] { + assert!(index.write_paths([path.to_owned()], None).is_err()); + } + } + + #[test] + fn process_lease_is_retained_until_owner_drops() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let identity = json!({"root": "fixture"}); + let owner = DirectoryIndex::open(&path, SourceKind::Local, &identity).unwrap(); + assert!( + matches!(DirectoryIndex::open(&path, SourceKind::Local, &identity), Err(IndexError::Io(error)) if error.kind() == std::io::ErrorKind::WouldBlock) + ); + drop(owner); + assert!(DirectoryIndex::open(&path, SourceKind::Local, &identity).is_ok()); + } + + #[test] + fn scan_queue_is_atomic_bounded_and_blocks_premature_completion() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + index.seed_scan(1).unwrap(); + assert_eq!(index.pending_scan_paths(1, ROW_BATCH_SIZE).unwrap(), vec![""]); + assert!(index.finish_generation(1, 0).is_err()); + index.writer.execute_batch("CREATE TRIGGER reject_scan_work BEFORE INSERT ON scan_queue WHEN NEW.path = 'abort' BEGIN SELECT RAISE(ABORT, 'injected'); END;").unwrap(); + assert!(index + .write_scan_children(1, [("alpha".to_owned(), true), ("abort".to_owned(), true)]) + .is_err()); + let unchanged = index + .search("", MAX_RESULT_LIMIT, true, &Arc::new(AtomicBool::new(false)), false) + .unwrap(); + assert_eq!(unchanged.directory_count, 0); + assert_eq!(unchanged.accepted_revision, 0); + assert!(index + .write_scan_children(1, (0..).map(|number| (format!("directory{number}"), true))) + .is_err()); + index + .write_scan_children(1, [("alpha".to_owned(), true), ("alpha/beta".to_owned(), false)]) + .unwrap(); + assert_eq!(index.pending_scan_paths(1, ROW_BATCH_SIZE).unwrap(), vec!["", "alpha"]); + index.finish_scan_parent(1, "").unwrap(); + index.finish_scan_parent(1, "alpha").unwrap(); + assert!(index.finish_generation(1, 0).unwrap()); + } + + #[test] + fn unresolved_issues_block_complete_selection_and_preserve_rows() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + index.write_paths(["accepted".to_owned()], None).unwrap(); + index.mark_uncertain().unwrap(); + assert!(!index.finish_generation(1, 0).unwrap()); + let response = index + .search("accepted", MAX_RESULT_LIMIT, true, &Arc::new(AtomicBool::new(false)), false) + .unwrap(); + assert_eq!(response.total_matches, 1); + assert_eq!(response.coverage, "uncertain"); + assert_eq!(response.issue_count, 1); + let (candidate, selected) = index.prepare_scan().unwrap(); + assert!(!selected); + assert_ne!(candidate, 1); + } + + #[test] + fn startup_event_loss_only_restarts_unobserved_empty_generation() { + for observed in ["empty", "rows", "queue", "issue"] { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &json!({"root": "startup-fixture"})).unwrap(); + index.mark_uncertain().unwrap(); + match observed { + "rows" => index.write_paths(["retained".into()], None).unwrap(), + "queue" => index.seed_scan(1).unwrap(), + "issue" => index.record_issue(1, "access_denied").unwrap(), + _ => {} + } + let (generation, selected) = index.prepare_scan().unwrap(); + assert_eq!(selected, observed == "empty"); + let response = index.search("", 3, false, &Arc::new(AtomicBool::new(false)), false).unwrap(); + assert_eq!(response.last_verified_at_ms, None); + if selected { + assert_eq!(generation, 1); + assert_eq!(response.coverage, "partial"); + assert_eq!(response.issue_count, 0); + } else { + assert_ne!(generation, 1); + assert_eq!(response.coverage, "uncertain"); + assert!(response.issue_count >= 1); + } + } + } + + #[test] + fn repeated_event_loss_is_idempotent_and_recovers() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let source = json!({"root": "fixture"}); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &source).unwrap(); + let cancelled = Arc::new(AtomicBool::new(false)); + index.write_paths(["accepted".to_owned()], None).unwrap(); + assert!(index.finish_generation(1, 0).unwrap()); + let accepted = index.search("accepted", 3, false, &cancelled, false).unwrap(); + index.mark_uncertain().unwrap(); + let uncertain = index.search("accepted", 3, false, &cancelled, false).unwrap(); + assert_eq!(uncertain.coverage, "uncertain"); + assert_eq!(uncertain.issue_count, 1); + assert_eq!(uncertain.last_verified_at_ms, accepted.last_verified_at_ms); + assert!(uncertain.accepted_revision > accepted.accepted_revision); + index + .writer + .execute_batch("CREATE TRIGGER reject_unchanged_loss BEFORE UPDATE ON generations BEGIN SELECT RAISE(ABORT, 'injected'); END") + .unwrap(); + for _ in 0..3 { + index.mark_uncertain().unwrap(); + assert_eq!(index.search("accepted", 3, false, &cancelled, false).unwrap(), uncertain); + } + index.writer.execute_batch("DROP TRIGGER reject_unchanged_loss").unwrap(); + let candidate = index.begin_verification().unwrap(); + index.write_paths(["accepted".to_owned()], Some(candidate)).unwrap(); + assert!(index.finish_generation(candidate, 0).unwrap()); + let recovered = index.search("accepted", 3, false, &cancelled, false).unwrap(); + assert_eq!(recovered.coverage, "complete"); + assert_eq!(recovered.issue_count, 0); + index.mark_uncertain().unwrap(); + let lost_again = index.search("accepted", 3, false, &cancelled, false).unwrap(); + assert_eq!(lost_again.coverage, "uncertain"); + assert_eq!(lost_again.issue_count, 1); + assert!(lost_again.accepted_revision > recovered.accepted_revision); + drop(index); + let reopened = DirectoryIndex::open(&path, SourceKind::Local, &source).unwrap(); + assert_eq!(reopened.search("accepted", 3, false, &cancelled, false).unwrap(), lost_again); + } + + #[test] + fn failed_event_loss_rolls_back_issue_metadata_and_revision() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + let cancelled = Arc::new(AtomicBool::new(false)); + index.write_paths(["accepted".to_owned()], None).unwrap(); + assert!(index.finish_generation(1, 0).unwrap()); + let before = index.search("accepted", 3, false, &cancelled, false).unwrap(); + index + .writer + .execute_batch( + "CREATE TRIGGER reject_loss_revision BEFORE UPDATE ON source_metadata BEGIN SELECT RAISE(ABORT, 'injected'); END", + ) + .unwrap(); + assert!(index.mark_uncertain().is_err()); + assert_eq!(index.search("accepted", 3, false, &cancelled, false).unwrap(), before); + let issues: i64 = index + .writer + .query_row("SELECT count(*) FROM coverage_issues", [], |row| row.get(0)) + .unwrap(); + assert_eq!(issues, 0); + index.writer.execute_batch("DROP TRIGGER reject_loss_revision").unwrap(); + index.mark_uncertain().unwrap(); + assert_eq!(index.search("accepted", 3, false, &cancelled, false).unwrap().issue_count, 1); + } + + #[test] + fn failed_generation_issue_counts_agree_with_rows() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + assert!(!index.finish_generation(1, 3).unwrap()); + let response = index + .search("", MAX_RESULT_LIMIT, true, &Arc::new(AtomicBool::new(false)), false) + .unwrap(); + let aggregate: i64 = index + .writer + .query_row("SELECT sum(count) FROM coverage_issues WHERE generation_id = 1", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(response.issue_count, 3); + assert_eq!(aggregate, 3); + } + + #[test] + fn independent_reader_sees_commits_without_borrowing_the_writer() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + let reader = index.reader(); + index.write_paths(["visible".to_owned()], None).unwrap(); + let result = std::thread::spawn(move || reader.search("visible", MAX_RESULT_LIMIT, true, &Arc::new(AtomicBool::new(false)), false)) + .join() + .unwrap() + .unwrap(); + assert_eq!(result.total_matches, 1); + } + + #[test] + fn subtree_mutations_reach_candidates_and_rollback_on_collision() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + index + .write_paths(["alpha".to_owned(), "alpha/beta".to_owned(), "occupied".to_owned()], None) + .unwrap(); + index.finish_generation(1, 0).unwrap(); + let candidate = index.begin_verification().unwrap(); + index + .write_paths(["alpha".to_owned(), "alpha/beta".to_owned()], Some(candidate)) + .unwrap(); + let cancellation = Arc::new(AtomicBool::new(false)); + let verified = index + .search("", MAX_RESULT_LIMIT, true, &cancellation, false) + .unwrap() + .last_verified_at_ms; + index + .mutate_path(&DirectoryMutation::Rename { + path: "alpha".to_owned(), + new_path: "archive".to_owned(), + }) + .unwrap(); + let renamed = index.search("archive", MAX_RESULT_LIMIT, true, &cancellation, false).unwrap(); + assert_eq!(renamed.total_matches, 2); + assert_eq!(renamed.last_verified_at_ms, verified); + assert!(index + .mutate_path(&DirectoryMutation::Rename { + path: "archive".to_owned(), + new_path: "occupied".to_owned() + }) + .is_err()); + assert_eq!( + index.search("archive", MAX_RESULT_LIMIT, true, &cancellation, false).unwrap(), + renamed + ); + let candidate_matches: i64 = index + .writer + .query_row( + "SELECT count(*) FROM directories WHERE generation_id = ? AND path LIKE 'archive%'", + [candidate], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(candidate_matches, 2); + index + .mutate_path(&DirectoryMutation::Remove { + path: "archive".to_owned(), + }) + .unwrap(); + assert_eq!( + index + .search("archive", MAX_RESULT_LIMIT, true, &cancellation, false) + .unwrap() + .total_matches, + 0 + ); + index.mutate_path(&DirectoryMutation::Add { path: "new".to_owned() }).unwrap(); + assert_eq!( + index + .search("new", MAX_RESULT_LIMIT, true, &cancellation, false) + .unwrap() + .total_matches, + 1 + ); + index.discard_generation(candidate).unwrap(); + } + + #[test] + fn accepted_parent_reconciliation_queues_only_new_subtrees() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let source = json!({"root": "fixture"}); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &source).unwrap(); + index.write_paths(["parent".into(), "parent/existing".into()], None).unwrap(); + assert!(index.finish_generation(1, 0).unwrap()); + let cancelled = Arc::new(AtomicBool::new(false)); + let before = index.search("parent", 3, false, &cancelled, false).unwrap(); + assert!(index.write_scan_children(1, [("parent/unowned".into(), false)]).is_err()); + assert!(index + .write_reconciliation_children(1, "parent", [("parent/unowned".into(), false)]) + .is_err()); + index.queue_reconciliation_parent(1, "parent").unwrap(); + assert_eq!(index.search("parent", 3, false, &cancelled, false).unwrap(), before); + drop(index); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &source).unwrap(); + assert_eq!(index.pending_scan_paths(1, ROW_BATCH_SIZE).unwrap(), vec!["parent"]); + index + .write_reconciliation_children(1, "parent", [("parent/existing".into(), true), ("parent/new".into(), true)]) + .unwrap(); + let after = index.search("parent", 3, false, &cancelled, false).unwrap(); + assert_eq!(after.directory_count, 3); + assert_eq!(after.accepted_revision, before.accepted_revision + 1); + assert_eq!(after.last_verified_at_ms, before.last_verified_at_ms); + assert_eq!(index.pending_scan_paths(1, ROW_BATCH_SIZE).unwrap(), vec!["parent", "parent/new"]); + index + .write_reconciliation_children(1, "parent", [("parent/existing".into(), true), ("parent/new".into(), true)]) + .unwrap(); + assert_eq!(index.search("parent", 3, false, &cancelled, false).unwrap(), after); + index + .write_reconciliation_children(1, "parent/new", [("parent/new/child".into(), false)]) + .unwrap(); + index.finish_reconciliation_parent(1, "parent/new").unwrap(); + index.finish_reconciliation_parent(1, "parent").unwrap(); + index.queue_reconciliation_parent(1, "parent").unwrap(); + let before_repeat = index.search("parent", 3, false, &cancelled, false).unwrap(); + index + .write_reconciliation_children(1, "parent", [("parent/new".into(), true)]) + .unwrap(); + assert_eq!(index.pending_scan_paths(1, ROW_BATCH_SIZE).unwrap(), vec!["parent"]); + assert_eq!(index.search("parent", 3, false, &cancelled, false).unwrap(), before_repeat); + assert_eq!(before_repeat.directory_count, 4); + assert_eq!(before_repeat.last_verified_at_ms, before.last_verified_at_ms); + } + + #[test] + fn accepted_parent_reconciliation_rejects_stale_and_unrelated_work() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + assert!(index.queue_reconciliation_parent(1, "").is_err()); + index.write_paths(["parent".into()], None).unwrap(); + assert!(index.finish_generation(1, 0).unwrap()); + index.queue_reconciliation_parent(1, "parent").unwrap(); + let cancelled = Arc::new(AtomicBool::new(false)); + let before = index.search("parent other", 3, false, &cancelled, false).unwrap(); + for invalid in ["other/child", "parent", "parent/deep/child"] { + assert!(index + .write_reconciliation_children(1, "parent", [("parent/safe".into(), false), (invalid.into(), false)]) + .is_err()); + assert_eq!(index.search("parent other", 3, false, &cancelled, false).unwrap(), before); + } + assert!(index + .write_reconciliation_children(1, "parent", (0..=ROW_BATCH_SIZE).map(|number| (format!("parent/{number}"), false))) + .is_err()); + assert_eq!(index.search("parent other", 3, false, &cancelled, false).unwrap(), before); + index.finish_reconciliation_parent(1, "parent").unwrap(); + assert!(index.finish_reconciliation_parent(1, "parent").is_err()); + assert!(index + .write_reconciliation_children(1, "parent", [("parent/late".into(), false)]) + .is_err()); + index.queue_reconciliation_parent(1, "parent").unwrap(); + let candidate = index.begin_verification().unwrap(); + assert!(index.queue_reconciliation_parent(candidate, "parent").is_err()); + assert!(index + .write_reconciliation_children(candidate, "parent", [("parent/late".into(), false)]) + .is_err()); + assert!(index.finish_reconciliation_parent(candidate, "parent").is_err()); + index.write_paths(["replacement".into()], Some(candidate)).unwrap(); + assert!(index.finish_generation(candidate, 0).unwrap()); + let selected = index.search("replacement parent", 3, false, &cancelled, false).unwrap(); + for generation in [1, i64::MAX] { + assert!(index.queue_reconciliation_parent(generation, "parent").is_err()); + assert!(index + .write_reconciliation_children(generation, "parent", [("parent/late".into(), false)]) + .is_err()); + assert!(index.finish_reconciliation_parent(generation, "parent").is_err()); + assert_eq!(index.search("replacement parent", 3, false, &cancelled, false).unwrap(), selected); + } + } + + #[test] + fn accepted_parent_reconciliation_bounds_bytes_and_root_children() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + index.write_paths(["existing".into()], None).unwrap(); + assert!(index.finish_generation(1, 0).unwrap()); + index.queue_reconciliation_parent(1, "").unwrap(); + let cancelled = Arc::new(AtomicBool::new(false)); + let before = index.search("existing root", 3, false, &cancelled, false).unwrap(); + let mut consumed = 0; + let children = [ + "a".repeat(SCAN_BATCH_BYTES / 2), + "b".repeat(SCAN_BATCH_BYTES / 2 + 1), + "not-consumed".into(), + ]; + assert!(index + .write_reconciliation_children( + 1, + "", + children.into_iter().map(|path| { + consumed += 1; + (path, true) + }) + ) + .is_err()); + assert_eq!(consumed, 2); + assert_eq!(index.search("existing root", 3, false, &cancelled, false).unwrap(), before); + assert_eq!(index.pending_scan_paths(1, ROW_BATCH_SIZE).unwrap(), vec![""]); + assert!(index + .write_reconciliation_children(1, "", [("root-child".into(), true), ("nested/child".into(), false)]) + .is_err()); + assert_eq!(index.search("existing root", 3, false, &cancelled, false).unwrap(), before); + index.write_reconciliation_children(1, "", [("root-child".into(), true)]).unwrap(); + index.finish_reconciliation_parent(1, "").unwrap(); + assert_eq!(index.pending_scan_paths(1, ROW_BATCH_SIZE).unwrap(), vec!["root-child"]); + let after = index.search("existing root", 3, false, &cancelled, false).unwrap(); + assert_eq!(after.directory_count, 2); + assert_eq!(after.last_verified_at_ms, before.last_verified_at_ms); + } + + #[test] + fn observed_mutations_queue_accepted_parents_atomically() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + index + .write_paths(["from".into(), "from/moved".into(), "from/moved/child".into(), "to".into()], None) + .unwrap(); + assert!(index.finish_generation(1, 0).unwrap()); + let cancelled = Arc::new(AtomicBool::new(false)); + let before = index.search("from to", 3, false, &cancelled, false).unwrap(); + let rename = DirectoryMutation::Rename { + path: "from/moved".into(), + new_path: "to/moved".into(), + }; + index.writer.execute_batch("CREATE TRIGGER reject_owned_parent BEFORE INSERT ON scan_queue WHEN NEW.generation_id = 1 AND NEW.path = 'to' BEGIN SELECT RAISE(ABORT, 'injected'); END;").unwrap(); + assert!(index.mutate_observed_path(&rename).is_err()); + assert_eq!(index.search("from to", 3, false, &cancelled, false).unwrap(), before); + assert!(index.reconciliation_generation().unwrap().is_none()); + assert!(index.pending_scan_paths(1, ROW_BATCH_SIZE).unwrap().is_empty()); + index.writer.execute_batch("DROP TRIGGER reject_owned_parent").unwrap(); + index.mutate_observed_path(&rename).unwrap(); + assert_eq!(index.pending_scan_paths(1, ROW_BATCH_SIZE).unwrap(), vec!["from", "to", "to/moved"]); + assert_eq!(index.reconciliation_generation().unwrap(), Some(1)); + let after = index.search("from to", 3, false, &cancelled, false).unwrap(); + assert_eq!(after.directory_count, before.directory_count); + assert_eq!(after.last_verified_at_ms, before.last_verified_at_ms); + assert_eq!(index.search("from/moved", 3, false, &cancelled, false).unwrap().total_matches, 0); + index + .mutate_observed_path(&DirectoryMutation::Add { path: "to/fresh".into() }) + .unwrap(); + let mut pending = index.pending_scan_paths(1, ROW_BATCH_SIZE).unwrap(); + pending.sort_unstable(); + assert_eq!(pending, ["from", "to", "to/fresh", "to/moved"]); + index + .mutate_observed_path(&DirectoryMutation::Remove { path: "to/fresh".into() }) + .unwrap(); + assert_eq!(index.pending_scan_paths(1, ROW_BATCH_SIZE).unwrap(), vec!["from", "to", "to/moved"]); + assert_eq!( + index.search("from to", 3, false, &cancelled, false).unwrap().last_verified_at_ms, + before.last_verified_at_ms + ); + } + + #[test] + fn failed_accepted_parent_reconciliation_rolls_back_rows_and_work() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + index.write_paths(["parent".into()], None).unwrap(); + assert!(index.finish_generation(1, 0).unwrap()); + index.queue_reconciliation_parent(1, "parent").unwrap(); + let cancelled = Arc::new(AtomicBool::new(false)); + let before = index.search("parent", 3, false, &cancelled, false).unwrap(); + for trigger in [ + "CREATE TRIGGER reject_reconciliation BEFORE INSERT ON scan_queue WHEN NEW.path = 'parent/abort' BEGIN SELECT RAISE(ABORT, 'injected'); END;", + "CREATE TRIGGER reject_reconciliation BEFORE UPDATE ON source_metadata BEGIN SELECT RAISE(ABORT, 'injected'); END;", + ] { + index.writer.execute_batch(trigger).unwrap(); + assert!(index.write_reconciliation_children(1, "parent", [("parent/abort".into(), true)]).is_err()); + assert_eq!(index.search("parent", 3, false, &cancelled, false).unwrap(), before); + assert_eq!(index.pending_scan_paths(1, ROW_BATCH_SIZE).unwrap(), vec!["parent"]); + index.writer.execute_batch("DROP TRIGGER reject_reconciliation").unwrap(); + } + index + .write_reconciliation_children(1, "parent", [("parent/abort".into(), true)]) + .unwrap(); + assert_eq!(index.search("parent", 3, false, &cancelled, false).unwrap().directory_count, 2); + assert_eq!(index.pending_scan_paths(1, ROW_BATCH_SIZE).unwrap(), vec!["parent", "parent/abort"]); + } + + #[test] + fn subtree_mutations_update_pending_scan_paths_transactionally() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + index.seed_scan(1).unwrap(); + index + .write_scan_children( + 1, + [ + ("alpha".to_owned(), true), + ("alpha/child".to_owned(), true), + ("other".to_owned(), true), + ], + ) + .unwrap(); + index.finish_scan_parent(1, "alpha").unwrap(); + index + .mutate_path(&DirectoryMutation::Rename { + path: "alpha".to_owned(), + new_path: "beta".to_owned(), + }) + .unwrap(); + assert_eq!( + index.pending_scan_paths(1, ROW_BATCH_SIZE).unwrap(), + vec!["", "beta", "beta/child", "other"] + ); + index.mutate_path(&DirectoryMutation::Remove { path: "beta".to_owned() }).unwrap(); + assert_eq!(index.pending_scan_paths(1, ROW_BATCH_SIZE).unwrap(), vec!["", "other"]); + assert_eq!( + index + .search("alpha beta", MAX_RESULT_LIMIT, true, &Arc::new(AtomicBool::new(false)), false) + .unwrap() + .total_matches, + 0 + ); + } + + #[test] + fn added_subtrees_are_queued_for_builds_and_candidates_atomically() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + let candidate = index.begin_verification().unwrap(); + index.mutate_path(&DirectoryMutation::Add { path: "moved-in".into() }).unwrap(); + for generation in [1, candidate] { + assert_eq!(index.pending_scan_paths(generation, ROW_BATCH_SIZE).unwrap(), vec!["moved-in"]); + index.finish_scan_parent(generation, "moved-in").unwrap(); + } + index.mutate_path(&DirectoryMutation::Add { path: "moved-in".into() }).unwrap(); + for generation in [1, candidate] { + assert_eq!(index.pending_scan_paths(generation, ROW_BATCH_SIZE).unwrap(), vec!["moved-in"]); + } + index.writer.execute_batch("CREATE TRIGGER reject_added_scan_work BEFORE INSERT ON scan_queue WHEN NEW.path = 'abort' BEGIN SELECT RAISE(ABORT, 'injected'); END;").unwrap(); + let before = index + .search("moved-in", 3, false, &Arc::new(AtomicBool::new(false)), false) + .unwrap(); + assert!(index.mutate_path(&DirectoryMutation::Add { path: "abort".into() }).is_err()); + assert_eq!( + index + .search("moved-in", 3, false, &Arc::new(AtomicBool::new(false)), false) + .unwrap(), + before + ); + assert_eq!( + index + .search("abort", 3, false, &Arc::new(AtomicBool::new(false)), false) + .unwrap() + .total_matches, + 0 + ); + index.finish_scan_parent(1, "moved-in").unwrap(); + assert!(index.finish_generation(1, 0).unwrap()); + let verified = index + .search("moved-in", 3, false, &Arc::new(AtomicBool::new(false)), false) + .unwrap() + .last_verified_at_ms; + index.mutate_path(&DirectoryMutation::Add { path: "later".into() }).unwrap(); + assert!(index.pending_scan_paths(1, ROW_BATCH_SIZE).unwrap().is_empty()); + let mut pending = index.pending_scan_paths(candidate, ROW_BATCH_SIZE).unwrap(); + pending.sort_unstable(); + assert_eq!(pending, ["later", "moved-in"]); + assert_eq!( + index + .search("later", 3, false, &Arc::new(AtomicBool::new(false)), false) + .unwrap() + .last_verified_at_ms, + verified + ); + } + + #[test] + fn mutation_subtree_pages_cover_candidates_without_changing_selected_data() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + index + .write_paths(["root%_/child".into(), "root%_-other/leak".into()], None) + .unwrap(); + index.finish_generation(1, 0).unwrap(); + let candidate = index.begin_verification().unwrap(); + let paths: Vec<_> = (0..ROW_BATCH_SIZE + 1).map(|child| format!("root%_/child/{child:04}")).collect(); + index.write_paths(paths.clone(), Some(candidate)).unwrap(); + let before = index.search("root", 3, false, &Arc::new(AtomicBool::new(false)), false).unwrap(); + assert_eq!(index.mutation_generations().unwrap(), vec![1, candidate]); + assert_eq!(index.mutation_subtree_paths(1, "root%_", None).unwrap(), vec!["root%_/child"]); + let first = index.mutation_subtree_paths(candidate, "root%_", None).unwrap(); + assert_eq!(first, paths[..ROW_BATCH_SIZE]); + assert_eq!( + index + .mutation_subtree_paths(candidate, "root%_", first.last().map(String::as_str)) + .unwrap(), + paths[ROW_BATCH_SIZE..] + ); + assert!(index.mutation_subtree_paths(candidate, "../root", None).is_err()); + assert_eq!( + index.search("root", 3, false, &Arc::new(AtomicBool::new(false)), false).unwrap(), + before + ); + index.discard_generation(candidate).unwrap(); + assert_eq!(index.mutation_generations().unwrap(), vec![1]); + } + + #[test] + fn direct_child_pages_preserve_original_names_and_byte_bounds() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + index + .write_paths( + [ + "root%_/a/one", + "root%_/a/two", + "root%_/a-b", + "root%_/b/child", + "root%_-other/leak", + "é/x", + "e\u{0301}/y", + ] + .map(str::to_owned), + None, + ) + .unwrap(); + assert_eq!( + index.direct_child_paths(1, "root%_", None, 18).unwrap(), + vec!["root%_/a", "root%_/a-b"] + ); + assert_eq!( + index.direct_child_paths(1, "root%_", Some("root%_/a-b"), 18).unwrap(), + vec!["root%_/b"] + ); + assert!(index + .direct_child_paths(1, "root%_", Some("root%_/b"), SCAN_BATCH_BYTES) + .unwrap() + .is_empty()); + assert_eq!( + index.direct_child_paths(1, "", None, SCAN_BATCH_BYTES).unwrap(), + vec!["e\u{0301}", "root%_", "root%_-other", "é"] + ); + assert!(index + .direct_child_paths(1, "root%_", Some("other/child"), SCAN_BATCH_BYTES) + .is_err()); + assert!(index.direct_child_paths(1, "root%_", None, 1).is_err()); + index + .write_paths((0..ROW_BATCH_SIZE * 2 + 1).map(|number| format!("many/{number:06}")), None) + .unwrap(); + let mut cursor: Option = None; + let mut count = 0; + loop { + let page = index.direct_child_paths(1, "many", cursor.as_deref(), SCAN_BATCH_BYTES).unwrap(); + assert!(page.len() <= ROW_BATCH_SIZE); + if page.is_empty() { + break; + } + count += page.len(); + cursor = page.last().cloned(); + } + assert_eq!(count, ROW_BATCH_SIZE * 2 + 1); + } + + #[test] + fn direct_child_pages_match_sorted_reference_with_interleaved_descendants() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + let mut names = [ + "a", + "a!", + "a!a", + "a!b", + "a-b", + "a.", + "a0", + "a !", + "a a", + "aä", + "!a", + "!a!", + "é", + "zzzzzzzzzzzzzzzzzzzz", + ]; + names.sort_unstable(); + index + .write_paths(names.iter().rev().map(|name| format!("parent/{name}/leaf")), None) + .unwrap(); + for maximum_bytes in [8, 9, 10, 12, 16, 24, 64] { + for after_path in [None, Some("parent/a"), Some("parent/a-b"), Some("parent/a0")] { + let mut expected = Vec::new(); + let mut bytes = 0; + let mut oversized = false; + for name in names { + let path = format!("parent/{name}"); + if after_path.is_some_and(|after| path.as_str() <= after) { + continue; + } + if path.len() > maximum_bytes { + oversized = true; + break; + } + if bytes + path.len() > maximum_bytes { + break; + } + bytes += path.len(); + expected.push(path); + } + let page = index.direct_child_paths(1, "parent", after_path, maximum_bytes); + if oversized { + assert!(matches!(page, Err(IndexError::Invalid(_)))); + } else { + assert_eq!(page.unwrap(), expected); + } + } + } + } + + #[test] + fn direct_child_pages_stop_before_reading_the_remaining_flat_tree() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + let row_count = ROW_BATCH_SIZE * 32; + index + .write_paths((0..row_count).map(|number| format!("parent/{number:06}")), None) + .unwrap(); + let steps = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counter = Arc::clone(&steps); + index + .writer + .progress_handler( + 1, + Some(move || { + counter.fetch_add(1, Ordering::Relaxed); + false + }), + ) + .unwrap(); + let page = index.direct_child_paths(1, "parent", None, SCAN_BATCH_BYTES); + index.writer.progress_handler(0, None:: bool>).unwrap(); + let page = page.unwrap(); + assert_eq!(page.len(), ROW_BATCH_SIZE); + assert_eq!(page.last().unwrap(), &format!("parent/{:06}", ROW_BATCH_SIZE - 1)); + assert!(steps.load(Ordering::Relaxed) < row_count); + } + + #[test] + fn missing_subtree_pruning_is_bounded_and_preserves_verification() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + let paths: Vec<_> = std::iter::once("gone".to_owned()) + .chain((0..ROW_BATCH_SIZE * 2).map(|number| format!("gone/{number:06}"))) + .collect(); + index + .write_paths(paths.iter().cloned().chain(["gone-other".into(), "keep".into()]), None) + .unwrap(); + index.finish_generation(1, 0).unwrap(); + index + .writer + .execute("UPDATE directories SET id = -1 WHERE path = 'gone'", []) + .unwrap(); + for path in &paths { + index + .writer + .execute("INSERT INTO scan_queue(generation_id, path) VALUES (1, ?)", [path]) + .unwrap(); + } + let cancelled = Arc::new(AtomicBool::new(false)); + let before = index.search("keep", 3, false, &cancelled, false).unwrap(); + let mut remaining = true; + let mut passes = 0; + while remaining { + let previous = index.search("keep", 3, false, &cancelled, false).unwrap().directory_count; + remaining = index.prune_subtree_batch(1, "gone").unwrap(); + let after = index.search("keep", 3, false, &cancelled, false).unwrap(); + assert!(previous - after.directory_count > 0); + assert!(previous - after.directory_count <= ROW_BATCH_SIZE as i64); + assert_eq!(after.last_verified_at_ms, before.last_verified_at_ms); + assert_eq!( + index.search("gone-other keep", 3, false, &cancelled, false).unwrap().total_matches, + 2 + ); + passes += 1; + } + assert_eq!(passes, 3); + assert!(index.pending_scan_paths(1, ROW_BATCH_SIZE).unwrap().is_empty()); + let completed = index.search("keep", 3, false, &cancelled, false).unwrap(); + assert!(!index.prune_subtree_batch(1, "gone").unwrap()); + assert_eq!(index.search("keep", 3, false, &cancelled, false).unwrap(), completed); + } + + #[test] + fn failed_missing_subtree_pruning_rolls_back_rows_queue_and_revision() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + index + .write_paths(["gone".into(), "gone/child".into(), "keep".into()], None) + .unwrap(); + index + .writer + .execute("INSERT INTO scan_queue(generation_id, path) VALUES (1, 'gone')", []) + .unwrap(); + index + .writer + .execute_batch("CREATE TRIGGER reject_prune_queue BEFORE DELETE ON scan_queue BEGIN SELECT RAISE(ABORT, 'injected'); END;") + .unwrap(); + let cancelled = Arc::new(AtomicBool::new(false)); + let before = index.search("gone keep", 3, false, &cancelled, false).unwrap(); + assert!(index.prune_subtree_batch(1, "gone").is_err()); + assert_eq!(index.search("gone keep", 3, false, &cancelled, false).unwrap(), before); + assert_eq!(index.pending_scan_paths(1, ROW_BATCH_SIZE).unwrap(), vec!["gone"]); + let candidate = index.begin_verification().unwrap(); + index.discard_generation(candidate).unwrap(); + assert!(index.prune_subtree_batch(candidate, "gone").is_err()); + assert!(index.prune_subtree_batch(1, "").is_err()); + let active = index.begin_verification().unwrap(); + index.write_paths(["gone".into(), "gone/child".into()], Some(active)).unwrap(); + assert!(!index.prune_subtree_batch(active, "gone").unwrap()); + assert_eq!(index.search("gone keep", 3, false, &cancelled, false).unwrap(), before); + } + + #[test] + fn parent_read_hints_queue_work_without_inserting_directory_names() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + let candidate = index.begin_verification().unwrap(); + for parent in ["", "parent"] { + index.mutate_path(&DirectoryMutation::ReadParent { path: parent.into() }).unwrap(); + } + for generation in [1, candidate] { + assert_eq!(index.pending_scan_paths(generation, ROW_BATCH_SIZE).unwrap(), vec!["", "parent"]); + } + assert_eq!( + index + .search("parent", 3, false, &Arc::new(AtomicBool::new(false)), false) + .unwrap() + .directory_count, + 0 + ); + assert!(index + .mutate_path(&DirectoryMutation::ReadParent { path: "../outside".into() }) + .is_err()); + } + + #[test] + fn scan_queue_rename_collision_rolls_back_directory_changes() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("source.sqlite3"); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + index.seed_scan(1).unwrap(); + index + .write_scan_children(1, [("alpha".to_owned(), true), ("occupied".to_owned(), true)]) + .unwrap(); + index + .mutate_path(&DirectoryMutation::Remove { + path: "occupied".to_owned(), + }) + .unwrap(); + index + .writer + .execute("INSERT INTO scan_queue(generation_id, path) VALUES (1, 'occupied')", []) + .unwrap(); + let cancellation = Arc::new(AtomicBool::new(false)); + let previous = index + .search("alpha occupied", MAX_RESULT_LIMIT, true, &cancellation, false) + .unwrap(); + assert!(index + .mutate_path(&DirectoryMutation::Rename { + path: "alpha".to_owned(), + new_path: "occupied".to_owned() + }) + .is_err()); + assert_eq!( + index + .search("alpha occupied", MAX_RESULT_LIMIT, true, &cancellation, false) + .unwrap(), + previous + ); + assert_eq!(index.pending_scan_paths(1, ROW_BATCH_SIZE).unwrap(), vec!["", "alpha", "occupied"]); + } + + #[cfg(unix)] + #[test] + fn rejects_public_storage_and_symbolic_links() { + let temporary = tempfile::tempdir().unwrap(); + fs::set_permissions(temporary.path(), fs::Permissions::from_mode(0o755)).unwrap(); + assert!(DirectoryIndex::open(&temporary.path().join("source.sqlite3"), SourceKind::Local, &json!({})).is_err()); + let link = temporary.path().join("link"); + std::os::unix::fs::symlink(temporary.path(), &link).unwrap(); + assert!(DirectoryIndex::open(&link.join("source.sqlite3"), SourceKind::Local, &json!({})).is_err()); + } + + #[test] + #[ignore = "Requires the backend test interpreter; run this compatibility test explicitly."] + fn opens_python_created_database_through_source_validation() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap().parent().unwrap(); + let executable = std::env::var_os("SAMBEE_INDEX_TEST_PYTHON") + .map(PathBuf::from) + .unwrap_or_else(|| root.join("backend/.venv/bin/python")); + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("private").join("python.sqlite3"); + let status = std::process::Command::new(&executable) + .env("PYTHONPATH", root.join("backend")) + .args(["-c", "import sys; from pathlib import Path; from app.services.directory_index import DirectoryIndex; store = DirectoryIndex(Path(sys.argv[1]), source_kind='local', source_identity={'root': 'fixture'}); store.open(); store.write_paths(['alpha/beta', 'beta', 'neither']); store.finish_generation(1); store.close()"]) + .arg(&path).status().expect("Backend test interpreter could not run. Set SAMBEE_INDEX_TEST_PYTHON to its executable path."); + assert!(status.success()); + assert!(DirectoryIndex::open(&path, SourceKind::Smb, &json!({"root": "fixture"})).is_err()); + let index = DirectoryIndex::open(&path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + let locked_status = std::process::Command::new(&executable) + .env("PYTHONPATH", root.join("backend")) + .args(["-c", "import sys; from pathlib import Path; from app.services.directory_index import DirectoryIndex; store = DirectoryIndex(Path(sys.argv[1]), source_kind='local', source_identity={'root': 'fixture'})\ntry:\n store.open()\nexcept BlockingIOError:\n pass\nelse:\n store.close(); raise SystemExit('Rust process lease did not exclude Python')"]) + .arg(&path).status().expect("Backend test interpreter could not run the lease compatibility check."); + assert!(locked_status.success()); + let response = index + .search("alpha beta", 10, false, &Arc::new(AtomicBool::new(false)), false) + .unwrap(); + assert_eq!(response.total_matches, 2); + assert_eq!(response.directory_count, 3); + assert_eq!(response.results[0].path, "alpha/beta"); + } +} diff --git a/companion/src-tauri/src/server/directory_manager.rs b/companion/src-tauri/src/server/directory_manager.rs new file mode 100644 index 000000000..f5062e92f --- /dev/null +++ b/companion/src-tauri/src/server/directory_manager.rs @@ -0,0 +1,5456 @@ +//! Shared local catalogues, native observations, and worker ownership independent of HTTP requests. + +use std::cmp::Reverse; +use std::collections::{BinaryHeap, HashMap, VecDeque}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock}; +use std::time::{Duration, Instant}; + +use notify::{ + event::{CreateKind, ModifyKind, RemoveKind, RenameMode}, + Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher, +}; +#[cfg(test)] +use rusqlite::Connection; +use rusqlite::OpenFlags; +use serde::Deserialize; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use tokio::sync::{Notify, OwnedSemaphorePermit, Semaphore}; + +use super::directory_index::{ + acquire_storage, epoch_milliseconds, open_directory_database, open_readonly_directory_database, search_terms, validate_directory_path, + validate_storage_files, DirectoryIndex, DirectoryIndexReader, DirectoryMutation, IndexError, SourceKind, StorageDirectory, + APPLICATION_ID, ROW_BATCH_SIZE, SCHEMA_VERSION, SQL_BUSY_TIMEOUT, +}; +use super::directory_traversal::{NativeIdentity, NativeRoot, MOUNT_BOUNDARY_CAPABILITY_ERROR}; +use super::drives::{volume_evidence, VolumeChange, VolumeEvidence}; +use super::models::DirectorySearchResult; + +const CONTRACT_JSON: &str = include_str!("../../../../shared/directory-index/contract.json"); +const STALLED_READ_TIMEOUT: Duration = Duration::from_secs(30); +const FAILURE_RETRY_MIN: Duration = Duration::from_secs(30); +const FAILURE_RETRY_MAX: Duration = Duration::from_secs(300); +const FAILURE_RETRY_JITTER_PERCENT: u8 = 10; +const PERIODIC_VERIFICATION_JITTER_PERCENT: u8 = 10; +const DEFAULT_RESULT_LIMIT: usize = 50; +const SEARCH_CONTRACT_VERSION: u32 = 1; +pub(super) const MAX_WIRE_INTEGER: i64 = 9_007_199_254_740_991; +const POLICY_VERSION: u32 = 1; + +#[cfg(windows)] +#[path = "directory_removal_windows.rs"] +mod removal_windows; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Contract { + version: u32, + limits: HashMap, + policy: HashMap, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PolicyField { + label: String, + description: String, + default: i64, + minimum: i64, + maximum: i64, + step: i64, +} + +fn contract() -> Result<&'static Contract, IndexError> { + static CONTRACT: OnceLock> = OnceLock::new(); + let contract = CONTRACT + .get_or_init(|| serde_json::from_str(CONTRACT_JSON)) + .as_ref() + .map_err(|_| IndexError::Invalid("Bundled directory-index policy is invalid. Reinstall the companion."))?; + if contract.version != POLICY_VERSION { + return Err(IndexError::Invalid( + "Unsupported directory policy contract. Reinstall the companion.", + )); + } + Ok(contract) +} + +pub(super) fn limit(name: &str) -> Result { + let value = contract()?.limits.get(name).ok_or(IndexError::Invalid( + "Bundled directory resource limit is missing. Reinstall the companion.", + ))?; + usize::try_from(*value).ok().filter(|value| *value > 0).ok_or(IndexError::Invalid( + "Bundled directory resource limit is invalid. Reinstall the companion.", + )) +} + +#[derive(Clone, Debug, Deserialize, serde::Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct DirectoryPolicy { + pub version: u32, + pub disk_budget_bytes: i64, + pub inactive_retention_seconds: i64, + pub max_retained_inactive_sources: i64, + pub page_cache_kib: i64, + pub healthy_verification_seconds: i64, + pub degraded_verification_seconds: i64, +} + +impl DirectoryPolicy { + pub fn defaults() -> Result { + let mut values = serde_json::Map::new(); + values.insert("version".into(), json!(POLICY_VERSION)); + for (name, field) in &contract()?.policy { + values.insert(name.clone(), json!(field.default)); + } + let policy: Self = serde_json::from_value(Value::Object(values)) + .map_err(|_| IndexError::Invalid("Bundled directory policy fields are invalid. Reinstall the companion."))?; + policy.validate()?; + Ok(policy) + } + + pub fn validate(&self) -> Result<(), IndexError> { + if self.version != POLICY_VERSION { + return Err(IndexError::Invalid("Directory policy version is not supported.")); + } + for (name, value) in [ + ("disk_budget_bytes", self.disk_budget_bytes), + ("inactive_retention_seconds", self.inactive_retention_seconds), + ("max_retained_inactive_sources", self.max_retained_inactive_sources), + ("page_cache_kib", self.page_cache_kib), + ("healthy_verification_seconds", self.healthy_verification_seconds), + ("degraded_verification_seconds", self.degraded_verification_seconds), + ] { + let field = contract()? + .policy + .get(name) + .ok_or(IndexError::Invalid("Directory policy field is not defined."))?; + if field.label.is_empty() || field.description.is_empty() || field.step <= 0 || value < field.minimum || value > field.maximum { + return Err(IndexError::Invalid("Directory policy value is outside its supported range.")); + } + } + if self.degraded_verification_seconds > self.healthy_verification_seconds { + return Err(IndexError::Invalid( + "Degraded verification must not be slower than healthy verification.", + )); + } + Ok(()) + } +} + +fn lock(value: &Mutex) -> Result, IndexError> { + value + .lock() + .map_err(|_| IndexError::Invalid("Directory worker state is unavailable. Restart the companion.")) +} + +struct DiskState { + policy: DirectoryPolicy, + reserved: u64, + next_cleanup_at: Instant, +} +struct DiskBudget { + namespace: PathBuf, + policy_gate: Mutex<()>, + state: Mutex, + space_probe: fn(&Path) -> Result, +} + +fn available_disk_bytes(namespace: &Path) -> Result { + let mut existing = None; + for ancestor in namespace.ancestors() { + match std::fs::symlink_metadata(ancestor) { + Ok(metadata) => { + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(IndexError::Invalid( + "Directory-index storage must use real directories without links. Select private local storage.", + )); + } + if existing.is_none() { + existing = Some(ancestor); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + let directory = existing.ok_or(IndexError::Invalid( + "Directory-index filesystem is unavailable. Restore cache storage before retrying.", + ))?; + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + use std::os::unix::fs::OpenOptionsExt; + let descriptor = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(directory)?; + let mut volume: libc::statvfs = unsafe { std::mem::zeroed() }; + if unsafe { libc::fstatvfs(descriptor.as_raw_fd(), &mut volume) } != 0 { + return Err(std::io::Error::last_os_error().into()); + } + u64::try_from(u128::from(volume.f_bavail) * u128::from(volume.f_frsize)) + .map_err(|_| IndexError::Invalid("Directory-index filesystem space exceeds the supported range.")) + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + use windows::core::PCWSTR; + use windows::Win32::Storage::FileSystem::GetDiskFreeSpaceExW; + let name: Vec = directory.as_os_str().encode_wide().chain(std::iter::once(0)).collect(); + let mut available = 0; + unsafe { GetDiskFreeSpaceExW(PCWSTR(name.as_ptr()), Some(&mut available), None, None) }.map_err(|_| { + IndexError::Invalid("Directory-index filesystem space cannot be checked. Restore cache storage before retrying.") + })?; + Ok(available) + } + #[cfg(not(any(unix, windows)))] + { + let _ = directory; + Err(IndexError::Invalid( + "Directory-index filesystem space checks are unavailable on this platform.", + )) + } +} + +impl DiskBudget { + fn inactive_access(path: &Path) -> Result, IndexError> { + if !path.try_exists()? { + return Ok(None); + } + let _lease = acquire_storage(path)?; + let connection = open_readonly_directory_database(path)?; + connection.busy_timeout(SQL_BUSY_TIMEOUT)?; + let application: i64 = connection.query_row("PRAGMA application_id", [], |row| row.get(0))?; + let schema: i64 = connection.query_row("PRAGMA user_version", [], |row| row.get(0))?; + if application != APPLICATION_ID || schema != SCHEMA_VERSION { + return Ok(None); + } + let (kind, accessed): (String, i64) = connection.query_row( + "SELECT source_kind, last_access_at_ms FROM source_metadata WHERE id = 1", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + Ok((kind == "local" && accessed >= 0).then_some(accessed)) + } + + fn delete_inactive(path: &Path, expected_access: i64) -> Result { + let directory = StorageDirectory::open( + path.parent() + .ok_or(IndexError::Invalid("Directory cleanup has no private parent."))?, + )?; + let _lease = acquire_storage(path)?; + if !path.try_exists()? { + return Ok(false); + } + let connection = open_directory_database(path, OpenFlags::SQLITE_OPEN_READ_WRITE)?; + connection.busy_timeout(SQL_BUSY_TIMEOUT)?; + let application: i64 = connection.query_row("PRAGMA application_id", [], |row| row.get(0))?; + let accessed: i64 = connection.query_row("SELECT last_access_at_ms FROM source_metadata WHERE id = 1", [], |row| row.get(0))?; + if application != APPLICATION_ID || accessed != expected_access { + return Ok(false); + } + let busy: i64 = connection.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| row.get(0))?; + if busy != 0 { + return Ok(false); + } + drop(connection); + directory.verify()?; + validate_storage_files(path)?; + for suffix in ["", "-wal", "-shm", "-journal"] { + let mut filename = path.as_os_str().to_os_string(); + filename.push(suffix); + match directory.remove(Path::new(&filename)) { + Ok(()) => {} + Err(IndexError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + } + log::debug!("event=directory_inactive_evicted"); + Ok(true) + } + + fn cleanup_inactive(&self, policy: &DirectoryPolicy, reserved: u64, pressure: bool, now_ms: i64) -> Result { + if pressure { + let _ = super::directory_index::discard_quarantine(&self.namespace); + } + let entries = match std::fs::read_dir(&self.namespace) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) => return Err(error.into()), + }; + let maximum = + usize::try_from(policy.max_retained_inactive_sources).map_err(|_| IndexError::Invalid("Invalid inactive-source count."))?; + let retention_ms = policy + .inactive_retention_seconds + .checked_mul(1000) + .ok_or(IndexError::Invalid("Invalid inactive retention time."))?; + let mut retained = BinaryHeap::new(); + let mut removed = 0; + for entry in entries { + let entry = entry?; + let name = entry.file_name(); + let Some(stem) = name.to_str().and_then(|name| name.strip_suffix(".sqlite3")) else { + continue; + }; + if stem.len() != 64 + || !stem + .bytes() + .all(|character| character.is_ascii_hexdigit() && !character.is_ascii_uppercase()) + { + continue; + } + let path = entry.path(); + let Ok(Some(accessed)) = Self::inactive_access(&path) else { + continue; + }; + let effective_access = if accessed > now_ms { 0 } else { accessed }; + if now_ms.saturating_sub(effective_access) >= retention_ms { + removed += usize::from(Self::delete_inactive(&path, accessed).unwrap_or(false)); + continue; + } + retained.push(Reverse((effective_access, path, accessed))); + if retained.len() > maximum { + if let Some(Reverse((_, path, accessed))) = retained.pop() { + removed += usize::from(Self::delete_inactive(&path, accessed).unwrap_or(false)); + } + } + } + let headroom = u64::try_from(limit("write_headroom_bytes")?).map_err(|_| IndexError::Invalid("Invalid cleanup headroom."))?; + while pressure && !retained.is_empty() { + let needed = self + .managed_bytes()? + .checked_add(reserved) + .and_then(|bytes| bytes.checked_add(headroom)) + .ok_or(IndexError::Invalid("Directory disk accounting overflowed."))?; + if needed <= u64::try_from(policy.disk_budget_bytes).map_err(|_| IndexError::Invalid("Invalid disk budget."))? + && reserved + .checked_add(headroom) + .ok_or(IndexError::Invalid("Directory disk accounting overflowed."))? + <= (self.space_probe)(&self.namespace)? + { + break; + } + let Some(Reverse((_, path, accessed))) = retained.pop() else { + break; + }; + removed += usize::from(Self::delete_inactive(&path, accessed).unwrap_or(false)); + } + Ok(removed) + } + + fn cleanup_orphan_leases(&self) -> Result { + let entries = match std::fs::read_dir(&self.namespace) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) => return Err(error.into()), + }; + let mut removed = 0; + for entry in entries { + if removed >= limit("lease_cleanup_batch")? { + break; + } + let entry = entry?; + let name = entry.file_name(); + let Some(stem) = name.to_str().and_then(|name| name.strip_suffix(".sqlite3.lease")) else { + continue; + }; + if stem.len() != 64 + || !stem + .bytes() + .all(|character| character.is_ascii_hexdigit() && !character.is_ascii_uppercase()) + { + continue; + } + let path = self.namespace.join(format!("{stem}.sqlite3")); + match super::directory_index::remove_orphan_lease(&path) { + Ok(deleted) => removed += usize::from(deleted), + Err(IndexError::Io(_)) => {} + Err(error) => return Err(error), + } + } + Ok(removed) + } + + fn managed_bytes(&self) -> Result { + let mut bytes: u64 = 0; + match std::fs::read_dir(&self.namespace) { + Ok(entries) => { + for entry in entries { + let metadata = std::fs::symlink_metadata(entry?.path())?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(IndexError::Invalid( + "Directory-index storage contains links or non-file entries. Select private local storage.", + )); + } + bytes = bytes + .checked_add(metadata.len()) + .ok_or(IndexError::Invalid("Directory disk accounting overflowed."))?; + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + Ok(bytes) + } + + fn reserve(self: &Arc) -> Result { + let mut state = lock(&self.state)?; + let mut bytes = self.managed_bytes()?; + let headroom = + u64::try_from(limit("write_headroom_bytes")?).map_err(|_| IndexError::Invalid("Directory operation headroom is invalid."))?; + let pressure = bytes + .checked_add(state.reserved) + .and_then(|bytes| bytes.checked_add(headroom)) + .ok_or(IndexError::Invalid("Directory disk accounting overflowed."))? + > u64::try_from(state.policy.disk_budget_bytes).map_err(|_| IndexError::Invalid("Invalid disk budget."))? + || state + .reserved + .checked_add(headroom) + .ok_or(IndexError::Invalid("Directory disk accounting overflowed."))? + > (self.space_probe)(&self.namespace)?; + if pressure || Instant::now() >= state.next_cleanup_at { + self.cleanup_inactive(&state.policy, state.reserved, pressure, epoch_milliseconds()?)?; + self.cleanup_orphan_leases()?; + state.next_cleanup_at = Instant::now() + + Duration::from_secs( + u64::try_from( + state + .policy + .inactive_retention_seconds + .min(state.policy.degraded_verification_seconds), + ) + .map_err(|_| IndexError::Invalid("Invalid cleanup interval."))?, + ); + bytes = self.managed_bytes()?; + } + let required = bytes + .checked_add(state.reserved) + .and_then(|bytes| bytes.checked_add(headroom)) + .ok_or(IndexError::Invalid("Directory disk reservation overflowed."))?; + if required > u64::try_from(state.policy.disk_budget_bytes).map_err(|_| IndexError::Invalid("Directory disk policy is invalid."))? { + return Err(IndexError::StoragePaused("Directory-index disk headroom is unavailable. Increase the companion disk budget or remove eligible inactive indexes, then retry.")); + } + if state + .reserved + .checked_add(headroom) + .ok_or(IndexError::Invalid("Directory disk reservation overflowed."))? + > (self.space_probe)(&self.namespace)? + { + return Err(IndexError::StoragePaused( + "Directory-index filesystem has insufficient free space. Free disk space or select another cache location, then retry.", + )); + } + state.reserved += headroom; + Ok(DiskReservation { + budget: Arc::clone(self), + bytes: headroom, + }) + } + + fn policy(&self) -> Result { + Ok(lock(&self.state)?.policy.clone()) + } +} + +struct DiskReservation { + budget: Arc, + bytes: u64, +} +impl Drop for DiskReservation { + fn drop(&mut self) { + match self.budget.state.lock() { + Ok(mut state) => state.reserved -= self.bytes, + Err(_) => log::warn!("Directory disk reservation could not be released. Restart the companion."), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ObservationToken { + epoch: u64, + sequence: u64, + producer: u64, +} + +#[derive(Default)] +struct Observations { + epoch: u64, + scan_epoch: Option<(i64, u64)>, + sequence: u64, + next_producer: u64, + producers: HashMap, + barriers: Vec<(u64, String)>, + barrier_bytes: usize, + uncertain: bool, + pending: VecDeque<(u64, DirectoryMutation, usize)>, + bytes: usize, + verification_required: bool, +} + +impl Observations { + fn invalidate(&mut self) -> Result<(), IndexError> { + self.epoch = self.epoch.checked_add(1).ok_or(IndexError::Invalid( + "Directory observation epoch is exhausted. Restart the companion.", + ))?; + self.barriers.clear(); + self.barrier_bytes = 0; + self.uncertain = true; + self.verification_required = true; + Ok(()) + } + + fn begin_read(&mut self) -> Result { + if self.producers.len() >= limit("max_pending_observations")? || self.next_producer == u64::MAX { + self.invalidate()?; + return Err(IndexError::Invalid( + "Directory read capacity is exhausted. Wait for active reads to finish.", + )); + } + self.next_producer += 1; + let token = ObservationToken { + epoch: self.epoch, + sequence: self.sequence, + producer: self.next_producer, + }; + self.producers.insert(token.producer, token); + Ok(token) + } + + fn is_current(&self, token: ObservationToken, parent: &str) -> bool { + self.producers.get(&token.producer) == Some(&token) + && token.epoch == self.epoch + && !self + .barriers + .iter() + .any(|(sequence, path)| *sequence > token.sequence && (parent.is_empty() || overlaps(path, parent))) + } + + fn can_capture(&self, token: ObservationToken) -> bool { + self.producers.get(&token.producer) == Some(&token) + && token.epoch == self.epoch + && !self.uncertain + && self.pending.is_empty() + && !self + .producers + .values() + .any(|other| other.epoch == self.epoch && other.producer != token.producer) + } + + fn end_read(&mut self, token: ObservationToken) { + if self.producers.get(&token.producer) == Some(&token) { + self.producers.remove(&token.producer); + self.retire_barriers(); + } + } + + fn retire_barriers(&mut self) { + let mut oldest = self + .producers + .values() + .filter(|token| token.epoch == self.epoch) + .map(|token| token.sequence) + .min() + .unwrap_or(self.sequence); + if let Some((sequence, _, _)) = self.pending.front() { + oldest = oldest.min(sequence.saturating_sub(1)); + } + self.barriers.retain(|(sequence, _)| *sequence > oldest); + self.barrier_bytes = self.barriers.iter().map(|(_, path)| path.len()).sum(); + } + + fn enqueue(&mut self, mutation: DirectoryMutation, maximum_pending: usize, maximum_bytes: usize) -> Result { + let paths = match &mutation { + DirectoryMutation::Add { path } | DirectoryMutation::Remove { path } | DirectoryMutation::ReadParent { path } => { + vec![path.as_str()] + } + DirectoryMutation::Rename { path, new_path } => vec![path.as_str(), new_path.as_str()], + }; + let bytes = paths.iter().map(|path| path.len()).sum::(); + if self.sequence == u64::MAX { + self.invalidate()?; + return Err(IndexError::Invalid( + "Directory observation sequence is exhausted. Restart the companion.", + )); + } + self.sequence += 1; + self.uncertain = true; + self.verification_required = true; + if self.pending.len() >= maximum_pending || bytes > maximum_bytes.saturating_sub(self.bytes) { + self.invalidate()?; + return Ok(false); + } + for path in paths { + let mut prefix = path.to_owned(); + self.barriers.retain(|(_, existing)| { + if is_prefix(existing, path) { + prefix.clone_from(existing); + false + } else { + !is_prefix(path, existing) + } + }); + self.barriers.push((self.sequence, prefix)); + } + self.barrier_bytes = self.barriers.iter().map(|(_, path)| path.len()).sum(); + self.pending.push_back((self.sequence, mutation, bytes)); + self.bytes += bytes; + if self.barriers.len() > maximum_pending || self.barrier_bytes > maximum_bytes { + self.invalidate()?; + } + Ok(true) + } +} + +fn is_prefix(parent: &str, path: &str) -> bool { + parent.is_empty() || path == parent || path.strip_prefix(parent).is_some_and(|suffix| suffix.starts_with('/')) +} + +fn parent_observation(path: &str) -> DirectoryMutation { + DirectoryMutation::ReadParent { + path: path.rsplit_once('/').map_or("", |(parent, _)| parent).to_owned(), + } +} + +fn overlaps(first: &str, second: &str) -> bool { + is_prefix(first, second) || is_prefix(second, first) +} + +struct ObservationRead { + source: Arc, + token: ObservationToken, +} + +impl Drop for ObservationRead { + fn drop(&mut self) { + match self.source.observations.lock() { + Ok(mut observations) => observations.end_read(self.token), + Err(_) => log::warn!("Directory read ownership could not be released. Restart the companion."), + } + } +} + +/// Retain producer ownership until the synchronous mutation worker actually exits. +struct MutationObservation { + read: ObservationRead, + parent: String, + subtree: Option, + ancestors: bool, +} + +impl MutationObservation { + fn observe(&self) { + let source = &self.read.source; + if source.stopped.load(Ordering::Acquire) { + return; + } + let outcome = (|| { + lock(&source.root)? + .as_ref() + .ok_or(IndexError::Invalid("Directory source identity is unavailable."))? + .verify()?; + let admitted = lock(&source.observations)?.enqueue( + DirectoryMutation::ReadParent { path: self.parent.clone() }, + limit("max_pending_observations")?, + limit("max_observation_bytes")?, + )?; + if !admitted { + return Ok(()); + } + if self.ancestors { + let mut parent = self.parent.as_str(); + while !parent.is_empty() { + parent = parent.rsplit_once('/').map_or("", |(ancestor, _)| ancestor); + if !lock(&source.observations)?.enqueue( + DirectoryMutation::ReadParent { path: parent.to_owned() }, + limit("max_pending_observations")?, + limit("max_observation_bytes")?, + )? { + return Ok(()); + } + } + } + if let Some(subtree) = &self.subtree { + let generations = lock(&source.writer)? + .as_ref() + .ok_or(IndexError::Invalid("Directory writer is unavailable."))? + .mutation_generations()?; + for generation in generations { + let mut cursor = None; + loop { + if source.stopped.load(Ordering::Acquire) { + return Ok(()); + } + let paths = lock(&source.writer)? + .as_ref() + .ok_or(IndexError::Invalid("Directory writer is unavailable."))? + .mutation_subtree_paths(generation, subtree, cursor.as_deref())?; + if paths.is_empty() { + break; + } + for path in paths { + let parent = path.rsplit_once('/').map_or("", |(parent, _)| parent).to_owned(); + if !lock(&source.observations)?.enqueue( + DirectoryMutation::ReadParent { path: parent }, + limit("max_pending_observations")?, + limit("max_observation_bytes")?, + )? { + return Ok(()); + } + cursor = Some(path); + } + } + } + } + Ok::<(), IndexError>(()) + })(); + if outcome.is_err() { + if source.invalidate().is_err() { + log::warn!("Directory mutation observations could not be invalidated. Restart the companion."); + } + log::warn!("Directory mutation reconciliation was not queued. Check drive access and retry verification."); + } + } +} + +impl Drop for MutationObservation { + fn drop(&mut self) { + self.observe(); + } +} + +struct SourceStatus { + state: &'static str, + message: Option, + progress: Instant, + verified: Instant, + accessed: Instant, + accessed_at_ms: Option, + retry_at: Instant, + failure_delay: Duration, + verification_jitter: u8, + stalled: bool, + watcher_retry_at: Instant, + watcher_failure_delay: Duration, +} + +impl SourceStatus { + fn retry_after_ms(&self) -> Option { + let now = Instant::now(); + [self.retry_at, self.watcher_retry_at] + .into_iter() + .filter(|deadline| *deadline > now) + .map(|deadline| u64::try_from(deadline.duration_since(now).as_millis()).unwrap_or(u64::MAX).max(1)) + .min() + } + + fn schedule_retry(&mut self, now: Instant, jitter: u8) { + self.failure_delay = if self.failure_delay.is_zero() { + FAILURE_RETRY_MIN + } else { + self.failure_delay.saturating_mul(2).min(FAILURE_RETRY_MAX) + }; + let percent = 100 - FAILURE_RETRY_JITTER_PERCENT + jitter % (FAILURE_RETRY_JITTER_PERCENT * 2 + 1); + let delay = self + .failure_delay + .mul_f64(f64::from(percent) / 100.0) + .clamp(FAILURE_RETRY_MIN, FAILURE_RETRY_MAX); + self.retry_at = now + delay; + } + + fn verification_due(&self, now: Instant, interval: Duration, initialized: bool) -> bool { + let percent = 100 - self.verification_jitter % (PERIODIC_VERIFICATION_JITTER_PERCENT + 1); + let interval = interval.mul_f64(f64::from(percent) / 100.0); + now >= self.retry_at && (!initialized || !self.failure_delay.is_zero() || now.saturating_duration_since(self.verified) >= interval) + } + + fn complete_pass(&mut self, now: Instant) { + self.verified = now; + self.retry_at = now; + self.failure_delay = Duration::ZERO; + } +} + +struct LocalSource { + root_path: PathBuf, + index_path: PathBuf, + source_id: String, + session: String, + volume: Mutex, + root: Mutex>>, + writer: Mutex>, + reader: Mutex>, + observations: Mutex, + status: Mutex, + watcher: Mutex>, + watcher_failed: AtomicBool, + watcher_reconciling: AtomicBool, + busy: AtomicBool, + flush_busy: AtomicBool, + cleanup_pending: AtomicBool, + reclamation_pending: AtomicBool, + active_queries: AtomicUsize, + stopped: AtomicBool, + root_invalid: AtomicBool, + recovery_attempted: AtomicBool, + capability_failed: AtomicBool, + media_unavailable: AtomicBool, + removal_drain: (Mutex<()>, Condvar), + #[cfg(windows)] + removal_watch: Mutex>, + next_full_pass: Mutex>, + finished: Notify, +} + +#[derive(Clone, Copy)] +struct ScanGeneration { + id: i64, + read: ObservationToken, +} + +impl LocalSource { + fn close_idle(&self, budget: &Arc) -> Result<(), IndexError> { + let mut writer = lock(&self.writer)?.take(); + if let (Some(index), Some(accessed)) = (writer.as_mut(), lock(&self.status)?.accessed_at_ms) { + match budget.reserve() { + Ok(_reservation) => { + if index.touch_access(accessed).is_err() { + log::warn!("Directory last-access time could not be saved. Closed cache data can be rebuilt after cleanup."); + } + } + Err(_) => log::warn!("Directory last-access time could not be saved. Closed cache data can be rebuilt after cleanup."), + } + } + lock(&self.reader)?.take(); + lock(&self.watcher)?.take(); + lock(&self.root)?.take(); + #[cfg(windows)] + lock(&self.removal_watch)?.take(); + drop(writer); + Ok(()) + } + + fn ensure_available(&self) -> Result<(), IndexError> { + if self.media_unavailable.load(Ordering::Acquire) { + return Err(std::io::Error::new( + std::io::ErrorKind::NotConnected, + "Directory volume is temporarily unavailable. Insert or mount the volume to resume search.", + ) + .into()); + } + if self.stopped.load(Ordering::Acquire) { + return Err(IndexError::Invalid( + "Directory source was replaced or stopped. Retry the search on the current drive.", + )); + } + Ok(()) + } + + fn touch(&self) -> Result<(), IndexError> { + let now = Instant::now(); + let accessed_at_ms = epoch_milliseconds()?; + let mut observations = lock(&self.observations)?; + let mut status = lock(&self.status)?; + if now.duration_since(status.accessed) >= Duration::from_secs(limit("idle_release_seconds")? as u64) { + observations.invalidate()?; + } + status.accessed = now; + status.accessed_at_ms = Some(accessed_at_ms); + Ok(()) + } + + #[cfg(test)] + fn new(root_path: PathBuf, namespace: &Path) -> Arc { + let evidence = volume_evidence(&root_path).unwrap_or_default(); + Self::with_volume(root_path, namespace, evidence) + } + + fn with_volume(root_path: PathBuf, namespace: &Path, mut evidence: VolumeEvidence) -> Arc { + let session = uuid::Uuid::new_v4().to_string(); + let source_id = format!("{:x}", Sha256::digest(root_path.as_os_str().as_encoded_bytes())); + let verification_jitter = Sha256::digest(session.as_bytes())[0]; + let identity = + json!({"kind": "local", "root": hex::encode(root_path.as_os_str().as_encoded_bytes()), "volume": evidence.index_volume()}); + let filename = format!("{:x}.sqlite3", Sha256::digest(identity.to_string().as_bytes())); + let mut index_path = namespace.join(filename); + if !index_path.exists() || cfg!(target_os = "linux") && !evidence.separate_mount { + if let Some((path, previous)) = Self::retained_boundary(namespace, &root_path, &evidence) { + index_path = path; + evidence.retain_available(&previous); + evidence.separate_mount |= previous.separate_mount; + } + } + let now = Instant::now(); + Arc::new(Self { + root_path, + index_path, + source_id, + session, + volume: Mutex::new(evidence), + root: Mutex::new(None), + writer: Mutex::new(None), + reader: Mutex::new(None), + watcher: Mutex::new(None), + watcher_failed: AtomicBool::new(false), + watcher_reconciling: AtomicBool::new(true), + observations: Mutex::new(Observations::default()), + status: Mutex::new(SourceStatus { + state: "building", + message: None, + progress: now, + verified: now, + accessed: now, + accessed_at_ms: None, + retry_at: now, + failure_delay: Duration::ZERO, + verification_jitter, + stalled: false, + watcher_retry_at: now, + watcher_failure_delay: Duration::ZERO, + }), + busy: AtomicBool::new(false), + flush_busy: AtomicBool::new(false), + cleanup_pending: AtomicBool::new(true), + reclamation_pending: AtomicBool::new(false), + recovery_attempted: AtomicBool::new(false), + capability_failed: AtomicBool::new(false), + media_unavailable: AtomicBool::new(false), + removal_drain: (Mutex::new(()), Condvar::new()), + #[cfg(windows)] + removal_watch: Mutex::new(None), + next_full_pass: Mutex::new(None), + active_queries: AtomicUsize::new(0), + stopped: AtomicBool::new(false), + root_invalid: AtomicBool::new(false), + finished: Notify::new(), + }) + } + + fn retained_boundary(namespace: &Path, root: &Path, current: &VolumeEvidence) -> Option<(PathBuf, VolumeEvidence)> { + let entries = std::fs::read_dir(namespace).ok()?; + let root = hex::encode(root.as_os_str().as_encoded_bytes()); + let mut retained = None; + for entry in entries.filter_map(Result::ok) { + let name = entry.file_name(); + let Some(stem) = name.to_str().and_then(|name| name.strip_suffix(".sqlite3")) else { + continue; + }; + if stem.len() != 64 || !stem.bytes().all(|byte| byte.is_ascii_hexdigit()) { + continue; + } + let Ok(database) = open_readonly_directory_database(&entry.path()) else { + continue; + }; + let Ok((kind, identity, accessed)) = database.query_row( + "SELECT source_kind, source_identity, last_access_at_ms FROM source_metadata WHERE id = 1", + [], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?)), + ) else { + continue; + }; + let Ok(identity) = serde_json::from_str::(&identity) else { + continue; + }; + if kind != "local" + || identity.get("root").and_then(Value::as_str) != Some(root.as_str()) + || identity.get("platform").and_then(Value::as_str) != Some(std::env::consts::OS) + { + continue; + } + let Some(previous) = identity + .get("evidence") + .cloned() + .and_then(|value| serde_json::from_value::(value).ok()) + else { + continue; + }; + if previous.compare(current) == VolumeChange::Index { + continue; + } + if retained + .as_ref() + .is_none_or(|(_, _, last): &(PathBuf, VolumeEvidence, i64)| accessed > *last) + { + retained = Some((entry.path(), previous, accessed)); + } + } + retained.map(|(path, evidence, _)| (path, evidence)) + } + + fn progress(&self) -> Result<(), IndexError> { + self.ensure_available()?; + lock(&self.status)?.progress = Instant::now(); + Ok(()) + } + + fn release_media_handles(&self) -> Result<(), IndexError> { + self.media_unavailable.store(true, Ordering::Release); + self.invalidate()?; + lock(&self.watcher)?.take(); + lock(&self.root)?.take(); + { + let mut status = lock(&self.status)?; + status.state = "ready"; + status.message = Some("Directory volume is temporarily unavailable. Insert or mount the volume to resume search.".into()); + status.retry_at = Instant::now() + FAILURE_RETRY_MIN; + } + Ok(()) + } + + #[cfg(any(windows, test))] + fn pause_for_removal(&self) -> Result { + self.release_media_handles()?; + let guard = lock(&self.removal_drain.0)?; + let (_guard, timeout) = self + .removal_drain + .1 + .wait_timeout_while(guard, STALLED_READ_TIMEOUT, |_| { + self.busy.load(Ordering::Acquire) + || self.flush_busy.load(Ordering::Acquire) + || self.active_queries.load(Ordering::Acquire) != 0 + }) + .map_err(|_| IndexError::Invalid("Directory removal state is unavailable. Restart the companion before removing the drive."))?; + Ok(!timeout.timed_out()) + } + + fn notify_worker_exit(&self) { + if let Ok(_guard) = self.removal_drain.0.lock() { + self.removal_drain.1.notify_all(); + } + let release = (|| -> Result<(), IndexError> { + if lock(&self.volume)?.removable() + && lock(&self.watcher)?.is_none() + && !self.busy.load(Ordering::Acquire) + && !self.flush_busy.load(Ordering::Acquire) + && self.active_queries.load(Ordering::Acquire) == 0 + && lock(&self.observations)?.producers.is_empty() + { + lock(&self.root)?.take(); + #[cfg(windows)] + lock(&self.removal_watch)?.take(); + } + Ok(()) + })(); + if release.is_err() { + log::warn!("Idle directory media handles could not be released. Restart the companion before removing the drive."); + } + } + + fn fail(&self, error: &IndexError, _budget: &DiskBudget) { + if matches!(error, IndexError::Io(error) if matches!(error.kind(), std::io::ErrorKind::NotFound | std::io::ErrorKind::NotConnected | std::io::ErrorKind::NotADirectory)) + && lock(&self.volume).is_ok_and(|volume| volume.removable()) + && self.release_media_handles().is_err() + { + log::warn!("Directory media handles could not be released. Restart the companion before removing the drive."); + } + if matches!(error, IndexError::Invalid(message) if *message == MOUNT_BOUNDARY_CAPABILITY_ERROR) { + self.capability_failed.store(true, Ordering::Release); + } + if matches!(error, IndexError::Interrupted) { + if let Ok(mut status) = self.status.lock() { + status.state = "ready"; + } + return; + } + if matches!(error, IndexError::StoragePaused(_)) { + self.reclamation_pending.store(true, Ordering::Release); + } + let message = match error { + IndexError::Invalid(message) | IndexError::RebuildRequired(message) | IndexError::StoragePaused(message) => { + (*message).to_owned() + } + _ => "Directory verification failed. Check drive access and private local storage, then retry.".to_owned(), + }; + if let Ok(mut status) = self.status.lock() { + if status.failure_delay.is_zero() { + let (error_type, os_code, sqlite_code) = match error { + IndexError::Io(cause) => ("Io", cause.raw_os_error(), None), + IndexError::Sql(cause) => ("Sql", None, cause.sqlite_error().map(|code| code.extended_code)), + IndexError::Invalid(_) => ("Invalid", None, None), + IndexError::RebuildRequired(_) => ("RebuildRequired", None, None), + IndexError::StoragePaused(_) => ("StoragePaused", None, None), + IndexError::QueryBusy => ("QueryBusy", None, None), + IndexError::Interrupted => ("Interrupted", None, None), + }; + log::warn!( + "event=directory_verification_unavailable operation=directory_index stage=verification source_id={} generation_id={} error_type={error_type} os_code={os_code:?} sqlite_code={sqlite_code:?}", + self.source_id, self.session + ); + } + status.state = "ready"; + status.message = Some(message); + status.schedule_retry(Instant::now(), Sha256::digest(self.session.as_bytes())[0]); + log::debug!( + "event=directory_retry_scheduled operation=directory_index stage=verification source_id={} generation_id={} retry_after_ms={}", + self.source_id, self.session, status.retry_after_ms().unwrap_or_default() + ); + } + } + + fn invalidate(&self) -> Result<(), IndexError> { + lock(&self.observations)?.invalidate() + } + + fn begin_read(self: &Arc) -> Result { + let token = lock(&self.observations)?.begin_read()?; + Ok(ObservationRead { + source: Arc::clone(self), + token, + }) + } + + fn observe(&self, event: Result) -> Result<(), IndexError> { + let Ok(event) = event else { + return self.lose_watcher().map(|_| ()); + }; + if event.need_rescan() { + return self.invalidate(); + } + if event.paths.len() > limit("max_pending_observations")? { + return self.invalidate(); + } + if matches!( + event.kind, + EventKind::Create(CreateKind::File) + | EventKind::Remove(RemoveKind::File) + | EventKind::Modify(ModifyKind::Data(_)) + | EventKind::Access(_) + ) { + return Ok(()); + } + let relative = |path: &Path| -> Result { + let relative = path + .strip_prefix(&self.root_path) + .map_err(|_| IndexError::Invalid("Directory observation is outside its bound source."))?; + let text = relative + .to_str() + .ok_or(IndexError::Invalid("Directory observation uses an unsupported native encoding."))?; + if text.len() > limit("max_observation_bytes")? { + return Err(IndexError::Invalid("Directory observation exceeds the pending byte limit.")); + } + #[cfg(windows)] + let text = text.replace('\\', "/"); + let text = text.to_owned(); + validate_directory_path(&text)?; + Ok(text) + }; + let maximum_pending = limit("max_pending_observations")?; + let maximum_bytes = limit("max_observation_bytes")?; + let mut observations = lock(&self.observations)?; + let mut enqueue = |mutation| observations.enqueue(mutation, maximum_pending, maximum_bytes); + match event.kind { + EventKind::Create(CreateKind::Folder) => { + for path in &event.paths { + if !enqueue(DirectoryMutation::Add { path: relative(path)? })? { + break; + } + } + } + EventKind::Remove(RemoveKind::Folder) => { + for path in &event.paths { + if !enqueue(DirectoryMutation::Remove { path: relative(path)? })? { + break; + } + } + } + EventKind::Modify(ModifyKind::Name(RenameMode::From)) => { + for path in &event.paths { + let path = relative(path)?; + if !enqueue(DirectoryMutation::Remove { path: path.clone() })? || !enqueue(parent_observation(&path))? { + break; + } + } + } + EventKind::Modify(ModifyKind::Name(RenameMode::Both)) if event.paths.len() == 2 => { + let old = relative(&event.paths[0])?; + let new = relative(&event.paths[1])?; + if enqueue(DirectoryMutation::Rename { + path: old.clone(), + new_path: new.clone(), + })? && enqueue(parent_observation(&old))? + { + enqueue(parent_observation(&new))?; + } + } + EventKind::Modify(ModifyKind::Name(_)) => { + for path in &event.paths { + if !enqueue(parent_observation(&relative(path)?))? { + break; + } + } + } + EventKind::Modify(ModifyKind::Metadata(_)) => return Ok(()), + _ => return observations.invalidate(), + } + Ok(()) + } + + fn lose_watcher(&self) -> Result { + let changed = !self.watcher_failed.swap(true, Ordering::AcqRel); + if changed { + log::warn!( + "event=directory_watcher_lost operation=directory_index stage=watcher source_id={} generation_id={} Directory observations were lost. Background verification is required.", + self.source_id, self.session + ); + } + self.invalidate()?; + Ok(changed) + } + + fn recover_watcher(self: &Arc) -> Result<(), IndexError> { + #[cfg(windows)] + if lock(&self.volume)?.removable() && !self.media_unavailable.load(Ordering::Acquire) { + let mut monitor = lock(&self.removal_watch)?; + if monitor.is_none() { + *monitor = Some(removal_windows::RemovalWatch::new(self)?); + } + } + let mut active = lock(&self.watcher)?; + if self.stopped.load(Ordering::Acquire) + || (active.is_some() && !self.watcher_failed.load(Ordering::Acquire)) + || Instant::now() < lock(&self.status)?.watcher_retry_at + { + return Ok(()); + } + active.take(); + if !self.watcher_failed.load(Ordering::Acquire) { + self.invalidate()?; + } + self.watcher_failed.store(false, Ordering::Release); + self.watcher_reconciling.store(true, Ordering::Release); + let weak = Arc::downgrade(self); + let outcome = notify::recommended_watcher(move |event| { + if let Some(source) = weak.upgrade() { + if source.observe(event).is_err() { + let _ = source.lose_watcher(); + } + } + }) + .and_then(|mut watcher| { + watcher.watch(&self.root_path, RecursiveMode::Recursive)?; + Ok(watcher) + }); + match outcome { + Ok(watcher) => { + *active = Some(watcher); + self.invalidate()?; + let mut status = lock(&self.status)?; + status.watcher_failure_delay = Duration::ZERO; + status.watcher_retry_at = Instant::now(); + log::info!( + "event=directory_watcher_reconciling operation=directory_index stage=watcher source_id={} generation_id={}", + self.source_id, + self.session + ); + } + Err(_) => { + self.watcher_failed.store(true, Ordering::Release); + let mut status = lock(&self.status)?; + status.watcher_failure_delay = if status.watcher_failure_delay.is_zero() { + FAILURE_RETRY_MIN + } else { + status.watcher_failure_delay.saturating_mul(2).min(FAILURE_RETRY_MAX) + }; + let percent = 100 - FAILURE_RETRY_JITTER_PERCENT + status.verification_jitter % (FAILURE_RETRY_JITTER_PERCENT * 2 + 1); + let delay = status + .watcher_failure_delay + .mul_f64(f64::from(percent) / 100.0) + .clamp(FAILURE_RETRY_MIN, FAILURE_RETRY_MAX); + status.watcher_retry_at = Instant::now() + delay; + status.message = Some("Directory monitoring is unavailable. Background verification remains active.".to_owned()); + if status.watcher_failure_delay == FAILURE_RETRY_MIN { + log::warn!( + "event=directory_watcher_unavailable operation=directory_index stage=watcher source_id={} generation_id={} retry_after_ms={} error_type=NotifyError", + self.source_id, self.session, delay.as_millis() + ); + } + } + } + Ok(()) + } + + fn cleanup_retired_work(&self, budget: &Arc) -> Result<(), IndexError> { + let slice_ms = + u64::try_from(limit("status_publication_ms")?).map_err(|_| IndexError::Invalid("Invalid directory worker time slice."))?; + let deadline = Instant::now() + Duration::from_millis(slice_ms); + while self.cleanup_pending.load(Ordering::Acquire) && Instant::now() < deadline { + if self.stopped.load(Ordering::Acquire) || self.busy.load(Ordering::Acquire) || self.active_queries.load(Ordering::Acquire) != 0 + { + break; + } + let observations = lock(&self.observations)?; + if observations.uncertain || !observations.pending.is_empty() { + break; + } + let mut writer = lock(&self.writer)?; + let Some(writer) = writer.as_mut() else { + break; + }; + if writer.needs_fts_repair() { + break; + } + if writer.reconciliation_generation()?.is_some() { + break; + } + let _reservation = budget.reserve()?; + self.cleanup_pending.store(writer.cleanup_retired_batch()?, Ordering::Release); + } + Ok(()) + } + + fn flush(&self, budget: &Arc) -> Result<(), IndexError> { + let mut observations = lock(&self.observations)?; + if !observations.uncertain + && observations.pending.is_empty() + && !self.cleanup_pending.load(Ordering::Acquire) + && !self.reclamation_pending.load(Ordering::Acquire) + { + if !self.busy.load(Ordering::Acquire) && self.active_queries.load(Ordering::Acquire) == 0 { + let writer = lock(&self.writer)?; + if let Some(writer) = writer.as_ref() { + if writer.reconciliation_generation()?.is_none() && writer.checkpoint_due()? { + let _reservation = budget.reserve()?; + writer.checkpoint_if_due()?; + } + } + } + return Ok(()); + } + let mut writer_guard = lock(&self.writer)?; + let Some(writer) = writer_guard.as_mut() else { + return Ok(()); + }; + if writer.needs_fts_repair() { + return Ok(()); + } + let _reservation = budget.reserve()?; + if observations.uncertain { + writer.mark_uncertain()?; + observations.uncertain = false; + } + for _ in 0..ROW_BATCH_SIZE { + let Some((_, mutation, bytes)) = observations.pending.front() else { + break; + }; + writer.mutate_observed_path(mutation)?; + observations.bytes -= *bytes; + observations.pending.pop_front(); + } + observations.retire_barriers(); + let reconciliation_pending = writer.reconciliation_generation()?.is_some(); + if !reconciliation_pending + && observations.pending.is_empty() + && !self.busy.load(Ordering::Acquire) + && self.active_queries.load(Ordering::Acquire) == 0 + && self.cleanup_pending.load(Ordering::Acquire) + { + self.cleanup_pending.store(writer.cleanup_retired_batch()?, Ordering::Release); + } + if !reconciliation_pending + && observations.pending.is_empty() + && !self.busy.load(Ordering::Acquire) + && self.active_queries.load(Ordering::Acquire) == 0 + && self.reclamation_pending.load(Ordering::Acquire) + { + self.reclamation_pending.store(!writer.reclaim_pages()?, Ordering::Release); + } + if !reconciliation_pending + && observations.pending.is_empty() + && !self.busy.load(Ordering::Acquire) + && self.active_queries.load(Ordering::Acquire) == 0 + { + writer.checkpoint_if_due()?; + } + drop(_reservation); + drop(writer_guard); + drop(observations); + self.cleanup_retired_work(budget)?; + Ok(()) + } + + fn commit_children( + &self, + root: &NativeRoot, + parent: &str, + parent_identity: NativeIdentity, + scan: ScanGeneration, + children: &[(String, bool)], + budget: &Arc, + ) -> Result<(), IndexError> { + root.verify_parent(parent, parent_identity)?; + let observations = lock(&self.observations)?; + if !observations.is_current(scan.read, parent) || self.stopped.load(Ordering::Acquire) { + return Err(IndexError::Interrupted); + } + let _reservation = budget.reserve()?; + let mut writer = lock(&self.writer)?; + let writer = writer.as_mut().ok_or(IndexError::Invalid("Directory writer is unavailable."))?; + if writer.accepted_generation()? == Some(scan.id) { + writer.write_reconciliation_children(scan.id, parent, children.iter().cloned())?; + } else { + writer.write_scan_children(scan.id, children.iter().cloned())?; + } + Ok(()) + } + + fn prune_missing_children( + &self, + root: &NativeRoot, + parent: &str, + parent_identity: NativeIdentity, + scan: ScanGeneration, + budget: &Arc, + ) -> Result<(), IndexError> { + let prefix = if parent.is_empty() { String::new() } else { format!("{parent}/") }; + let mut cursor: Option = None; + loop { + self.progress()?; + root.verify_parent(parent, parent_identity)?; + let paths = { + let observations = lock(&self.observations)?; + if self.stopped.load(Ordering::Acquire) || !observations.is_current(scan.read, parent) { + return Err(IndexError::Interrupted); + } + lock(&self.writer)? + .as_ref() + .ok_or(IndexError::Invalid("Directory writer is unavailable."))? + .direct_child_paths(scan.id, parent, cursor.as_deref(), limit("max_observation_bytes")?)? + }; + if paths.is_empty() { + return Ok(()); + } + for path in paths { + self.progress()?; + { + let observations = lock(&self.observations)?; + if self.stopped.load(Ordering::Acquire) || !observations.is_current(scan.read, parent) { + return Err(IndexError::Interrupted); + } + } + let name = path.strip_prefix(&prefix).ok_or(IndexError::Invalid( + "Indexed child is outside its observed parent. Rebuild this source.", + ))?; + if !root.has_directory_child_with_check(parent, parent_identity, name, || { + self.progress()?; + let observations = lock(&self.observations)?; + if self.stopped.load(Ordering::Acquire) || !observations.is_current(scan.read, parent) { + return Err(IndexError::Interrupted); + } + Ok(()) + })? { + loop { + self.progress()?; + root.verify_parent(parent, parent_identity)?; + let observations = lock(&self.observations)?; + if self.stopped.load(Ordering::Acquire) || !observations.is_current(scan.read, parent) { + return Err(IndexError::Interrupted); + } + let _reservation = budget.reserve()?; + if !lock(&self.writer)? + .as_mut() + .ok_or(IndexError::Invalid("Directory writer is unavailable."))? + .prune_subtree_batch(scan.id, &path)? + { + break; + } + } + } + cursor = Some(path); + } + } + } + + fn accept_generation( + &self, + root: &NativeRoot, + baseline: ObservationToken, + generation: i64, + budget: &Arc, + ) -> Result<(), IndexError> { + root.verify()?; + let mut observations = lock(&self.observations)?; + if !observations.can_capture(baseline) || self.stopped.load(Ordering::Acquire) { + return Err(IndexError::Interrupted); + } + let _reservation = budget.reserve()?; + let mut writer = lock(&self.writer)?; + let writer = writer.as_mut().ok_or(IndexError::Invalid("Directory writer is unavailable."))?; + if writer.reconciliation_generation()?.is_some() || !writer.pending_scan_paths(generation, 1)?.is_empty() { + return Err(IndexError::Interrupted); + } + let complete = writer.finish_generation(generation, 0)?; + let mut status = lock(&self.status)?; + status.state = "ready"; + if !status.failure_delay.is_zero() { + log::info!( + "event=directory_verification_recovered operation=directory_index stage=verification source_id={} generation_id={}", + self.source_id, + self.session + ); + } + status.complete_pass(Instant::now()); + if complete { + self.cleanup_pending.store(true, Ordering::Release); + observations.verification_required = false; + if self.watcher_reconciling.swap(false, Ordering::AcqRel) && !self.watcher_failed.load(Ordering::Acquire) { + log::info!( + "event=directory_watcher_recovered operation=directory_index stage=watcher source_id={} generation_id={}", + self.source_id, + self.session + ); + } + status.message = if self.watcher_failed.load(Ordering::Acquire) { + Some("Directory monitoring is unavailable. Background verification remains active.".to_owned()) + } else { + None + }; + } else { + status.message = Some("Directory coverage is incomplete. Some paths cannot be indexed safely.".to_owned()); + observations.verification_required = true; + } + Ok(()) + } + + fn scan_parent(self: &Arc, root: &NativeRoot, generation: i64, parent: &str, budget: &Arc) -> Result<(), IndexError> { + self.with_parent_read(root, parent, |read| self.scan_parent_read(root, generation, parent, read, budget)) + } + + fn with_parent_read( + self: &Arc, + root: &NativeRoot, + parent: &str, + operation: impl FnOnce(&ObservationRead) -> Result<(), IndexError>, + ) -> Result<(), IndexError> { + let read = self.begin_read()?; + match operation(&read) { + Err(IndexError::Io(error)) + if !parent.is_empty() && matches!(error.kind(), std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory) => + { + root.verify()?; + match root.parent_identity(parent) { + Err(IndexError::Io(current)) + if matches!(current.kind(), std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory) => {} + Ok(_) => return Err(IndexError::Io(error)), + Err(error) => return Err(error), + } + let mut observations = lock(&self.observations)?; + if self.stopped.load(Ordering::Acquire) || !observations.is_current(read.token, parent) { + return Err(IndexError::Interrupted); + } + observations.enqueue( + parent_observation(parent), + limit("max_pending_observations")?, + limit("max_observation_bytes")?, + )?; + Err(IndexError::Interrupted) + } + result => result, + } + } + + fn scan_parent_read( + self: &Arc, + root: &NativeRoot, + generation: i64, + parent: &str, + read: &ObservationRead, + budget: &Arc, + ) -> Result<(), IndexError> { + let parent_identity = root.parent_identity(parent)?; + let mut batch = Vec::with_capacity(ROW_BATCH_SIZE); + let mut bytes = 0; + self.progress()?; + root.children(parent, |entry| { + self.progress()?; + if self.stopped.load(Ordering::Acquire) || !lock(&self.observations)?.is_current(read.token, parent) { + return Err(IndexError::Interrupted); + } + match entry { + Ok(Some((name, descend))) => { + let path = if parent.is_empty() { name } else { format!("{parent}/{name}") }; + validate_directory_path(&path)?; + if path.len() > limit("max_observation_bytes")? { + return Err(IndexError::Invalid( + "Directory path exceeds the scan-batch byte limit. Source coverage is incomplete.", + )); + } + if !batch.is_empty() && (batch.len() == ROW_BATCH_SIZE || bytes + path.len() > limit("max_observation_bytes")?) { + self.commit_children( + root, + parent, + parent_identity, + ScanGeneration { + id: generation, + read: read.token, + }, + &batch, + budget, + )?; + batch.clear(); + bytes = 0; + } + bytes += path.len(); + batch.push((path, descend)); + } + Ok(None) => {} + Err(issue) => { + root.verify_parent(parent, parent_identity)?; + let observations = lock(&self.observations)?; + if !observations.is_current(read.token, parent) || self.stopped.load(Ordering::Acquire) { + return Err(IndexError::Interrupted); + } + let _reservation = budget.reserve()?; + lock(&self.writer)? + .as_mut() + .ok_or(IndexError::Invalid("Directory writer is unavailable."))? + .record_issue(generation, issue.code())?; + } + } + Ok(()) + })?; + if !batch.is_empty() { + self.commit_children( + root, + parent, + parent_identity, + ScanGeneration { + id: generation, + read: read.token, + }, + &batch, + budget, + )?; + } + self.prune_missing_children( + root, + parent, + parent_identity, + ScanGeneration { + id: generation, + read: read.token, + }, + budget, + )?; + root.verify_parent(parent, parent_identity)?; + let observations = lock(&self.observations)?; + if self.stopped.load(Ordering::Acquire) || !observations.is_current(read.token, parent) { + return Err(IndexError::Interrupted); + } + let _reservation = budget.reserve()?; + let mut writer = lock(&self.writer)?; + let writer = writer.as_mut().ok_or(IndexError::Invalid("Directory writer is unavailable."))?; + if writer.accepted_generation()? == Some(generation) { + writer.finish_reconciliation_parent(generation, parent)?; + } else { + writer.finish_scan_parent(generation, parent)?; + } + Ok(()) + } + + fn reconcile(self: &Arc, budget: &Arc, scans: &Arc) -> Result<(), IndexError> { + if self.stopped.load(Ordering::Acquire) || Instant::now() < lock(&self.status)?.retry_at { + return Ok(()); + } + let generation = { + let writer = lock(&self.writer)?; + match writer.as_ref() { + Some(writer) if !writer.needs_fts_repair() => writer.reconciliation_generation()?, + _ => None, + } + }; + let Some(_) = generation else { + return Ok(()); + }; + if self + .busy + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Ok(()); + } + let Ok(permit) = Arc::clone(scans).try_acquire_owned() else { + self.busy.store(false, Ordering::Release); + return Ok(()); + }; + let _ownership = ScanOwnership { + source: Arc::clone(self), + _permit: permit, + }; + self.progress()?; + let outcome = (|| { + if self.stopped.load(Ordering::Acquire) { + return Err(IndexError::Interrupted); + } + let root = self.bind_root(budget)?; + let Some(generation) = lock(&self.writer)? + .as_ref() + .ok_or(IndexError::Invalid("Directory writer is unavailable."))? + .reconciliation_generation()? + else { + return Ok(()); + }; + root.verify()?; + let baseline = self.begin_read()?; + loop { + if self.stopped.load(Ordering::Acquire) || !lock(&self.observations)?.is_current(baseline.token, "") { + return Err(IndexError::Interrupted); + } + let parents = lock(&self.writer)? + .as_ref() + .ok_or(IndexError::Invalid("Directory writer is unavailable."))? + .pending_scan_paths(generation, 1)?; + let Some(parent) = parents.first() else { + break; + }; + self.scan_parent(&root, generation, parent, budget)?; + } + root.verify()?; + let observations = lock(&self.observations)?; + if self.stopped.load(Ordering::Acquire) || !observations.is_current(baseline.token, "") { + return Err(IndexError::Interrupted); + } + Ok(()) + })(); + if outcome.is_err() { + self.invalidate()?; + } + outcome + } + + fn scan_to_acceptance( + self: &Arc, + root: &NativeRoot, + baseline: ObservationToken, + generation: i64, + budget: &Arc, + ) -> Result<(), IndexError> { + self.scan_to_acceptance_with(root, baseline, generation, budget, |generation, parent| { + self.scan_parent(root, generation, parent, budget) + }) + } + + fn scan_to_acceptance_with( + self: &Arc, + root: &NativeRoot, + baseline: ObservationToken, + generation: i64, + budget: &Arc, + mut scan_parent: impl FnMut(i64, &str) -> Result<(), IndexError>, + ) -> Result<(), IndexError> { + loop { + if self.stopped.load(Ordering::Acquire) { + return Err(IndexError::Interrupted); + } + let needs_flush = { + let observations = lock(&self.observations)?; + if observations.epoch != baseline.epoch { + return Err(IndexError::Interrupted); + } + observations.uncertain || !observations.pending.is_empty() + }; + if needs_flush { + self.flush(budget)?; + } + if !lock(&self.observations)?.pending.is_empty() { + continue; + } + let (work_generation, parents) = { + let writer = lock(&self.writer)?; + let writer = writer.as_ref().ok_or(IndexError::Invalid("Directory writer is unavailable."))?; + let work_generation = writer.reconciliation_generation()?.unwrap_or(generation); + (work_generation, writer.pending_scan_paths(work_generation, 1)?) + }; + let Some(parent) = parents.first() else { + if lock(&self.observations)?.scan_epoch != Some((generation, baseline.epoch)) { + loop { + let mut observations = lock(&self.observations)?; + if self.stopped.load(Ordering::Acquire) || observations.epoch != baseline.epoch { + return Err(IndexError::Interrupted); + } + let _reservation = budget.reserve()?; + let more = lock(&self.writer)? + .as_mut() + .ok_or(IndexError::Invalid("Directory writer is unavailable."))? + .requeue_completed_scan_batch(generation)?; + if !more { + observations.scan_epoch = Some((generation, baseline.epoch)); + break; + } + } + continue; + } + break; + }; + scan_parent(work_generation, parent)?; + } + self.accept_generation(root, baseline, generation, budget) + } + + #[cfg(test)] + fn scan(self: &Arc, budget: &Arc) -> Result<(), IndexError> { + self.scan_with_mode(budget, false) + } + + fn scan_with_mode(self: &Arc, budget: &Arc, rebind_only: bool) -> Result<(), IndexError> { + let started = Instant::now(); + log::debug!( + "event=directory_scan_started operation=directory_index stage=scan source_id={} generation_id={}", + self.source_id, + self.session + ); + let outcome = self.scan_source(budget, rebind_only); + log::debug!( + "event=directory_scan_finished operation=directory_index stage=scan source_id={} generation_id={} success={} duration_ms={}", + self.source_id, + self.session, + outcome.is_ok(), + started.elapsed().as_millis() + ); + outcome + } + + fn bind_root(self: &Arc, budget: &Arc) -> Result, IndexError> { + self.progress()?; + let evidence = volume_evidence(&self.root_path)?; + match lock(&self.volume)?.compare(&evidence) { + VolumeChange::Unavailable => { + return Err(std::io::Error::new( + std::io::ErrorKind::NotConnected, + "Directory volume is not mounted. Mount the volume to resume verification.", + ) + .into()) + } + VolumeChange::Index => { + return Err(std::io::Error::new( + std::io::ErrorKind::NotConnected, + "Directory volume changed. Retry the search to bind its index.", + ) + .into()) + } + _ => {} + } + let root = match lock(&self.root)?.clone() { + Some(root) => { + if root.verify().is_ok() { + root + } else { + self.invalidate()?; + lock(&self.watcher)?.take(); + Arc::new(NativeRoot::open(&self.root_path)?) + } + } + None => Arc::new(NativeRoot::open(&self.root_path)?), + }; + if lock(&self.writer)?.is_none() { + let _reservation = budget.reserve()?; + let mut index = DirectoryIndex::open_with_recovery( + &self.index_path, + SourceKind::Local, + &json!({"platform": std::env::consts::OS, "root": hex::encode(self.root_path.as_os_str().as_encoded_bytes())}), + Some(&self.recovery_attempted), + )?; + let _policy_gate = lock(&budget.policy_gate)?; + index.configure_page_cache(budget.policy()?.page_cache_kib)?; + match index.bind_volume(&evidence)? { + VolumeChange::Index | VolumeChange::Unavailable => { + return Err(std::io::Error::new( + std::io::ErrorKind::NotConnected, + "Directory volume is unavailable. Restore the volume and retry the search.", + ) + .into()) + } + _ => {} + } + index.mark_uncertain()?; + self.invalidate()?; + *lock(&self.reader)? = Some(index.reader()); + *lock(&self.writer)? = Some(index); + *lock(&self.root)? = Some(Arc::clone(&root)); + } + { + let _reservation = budget.reserve()?; + let mut writer = lock(&self.writer)?; + let index = writer.as_mut().ok_or(IndexError::Invalid("Directory writer is unavailable."))?; + match index.bind_volume(&evidence)? { + VolumeChange::Index | VolumeChange::Unavailable => { + return Err(std::io::Error::new( + std::io::ErrorKind::NotConnected, + "Directory volume is unavailable. Restore the volume and retry the search.", + ) + .into()) + } + VolumeChange::Generation => { + self.invalidate()?; + self.cleanup_pending.store(true, Ordering::Release); + } + VolumeChange::Keep => {} + } + } + let mut retained_evidence = lock(&self.volume)?; + let mut evidence = evidence; + evidence.retain_available(&retained_evidence); + *retained_evidence = evidence; + drop(retained_evidence); + *lock(&self.root)? = Some(Arc::clone(&root)); + Ok(root) + } + + fn scan_source(self: &Arc, budget: &Arc, rebind_only: bool) -> Result<(), IndexError> { + let root = self.bind_root(budget)?; + self.recover_watcher()?; + if rebind_only { + lock(&self.status)?.state = "ready"; + return Ok(()); + } + loop { + if self.stopped.load(Ordering::Acquire) { + return Err(IndexError::Interrupted); + } + self.progress()?; + let mut writer = lock(&self.writer)?; + let index = writer.as_mut().ok_or(IndexError::Invalid("Directory writer is unavailable."))?; + if !index.needs_fts_repair() { + break; + } + let _reservation = budget.reserve()?; + index.repair_fts_batch()?; + } + self.flush(budget)?; + let baseline = self.begin_read()?; + let (generation, _selected) = { + let mut observations = lock(&self.observations)?; + if !observations.is_current(baseline.token, "") { + return Err(IndexError::Interrupted); + } + let _reservation = budget.reserve()?; + let mut writer = lock(&self.writer)?; + let index = writer.as_mut().ok_or(IndexError::Invalid("Directory writer is unavailable."))?; + let resuming = index.has_resumable_scan()?; + let (generation, selected) = index.prepare_scan()?; + if !resuming { + observations.scan_epoch = Some((generation, baseline.token.epoch)); + } + self.cleanup_pending.store(true, Ordering::Release); + index.seed_scan(generation)?; + (generation, selected) + }; + let outcome = self.scan_to_acceptance(&root, baseline.token, generation, budget); + if outcome.is_err() { + self.invalidate()?; + } + outcome + } +} + +struct ScanOwnership { + source: Arc, + _permit: OwnedSemaphorePermit, +} +impl Drop for ScanOwnership { + fn drop(&mut self) { + self.source.busy.store(false, Ordering::Release); + self.source.finished.notify_waiters(); + self.source.notify_worker_exit(); + } +} + +struct QueryCancellation(Arc); +struct QueryOwnership { + source: Arc, + _permit: OwnedSemaphorePermit, +} + +impl Drop for QueryOwnership { + fn drop(&mut self) { + self.source.active_queries.fetch_sub(1, Ordering::AcqRel); + self.source.notify_worker_exit(); + } +} + +struct UpdateOwnership { + source: Arc, + _permit: OwnedSemaphorePermit, +} + +impl Drop for UpdateOwnership { + fn drop(&mut self) { + self.source.flush_busy.store(false, Ordering::Release); + self.source.notify_worker_exit(); + } +} +impl Drop for QueryCancellation { + fn drop(&mut self) { + self.0.store(true, Ordering::Release); + } +} + +pub struct LocalDirectoryManager { + sources: Mutex>>, + budget: Arc, + scans: Arc, + queries: Arc, + updates: Arc, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum MutationScope { + Parent, + Subtree, + Ancestors, +} + +impl LocalDirectoryManager { + #[cfg(feature = "recovery-verification")] + pub(super) fn verification_watcher_loss(&self, drive: &str, restore: bool) -> Result<(), IndexError> { + let source = lock(&self.sources)? + .get(drive) + .cloned() + .ok_or(IndexError::Invalid("Verification source is not active."))?; + if restore { + lock(&source.status)?.watcher_retry_at = Instant::now(); + source.recover_watcher()?; + let mut status = lock(&source.status)?; + status.verified = Instant::now() - FAILURE_RETRY_MAX; + status.retry_at = Instant::now(); + } else { + lock(&source.watcher)?.take(); + source.lose_watcher()?; + lock(&source.status)?.watcher_retry_at = Instant::now() + FAILURE_RETRY_MAX; + } + Ok(()) + } + + #[cfg(feature = "recovery-verification")] + pub(super) fn verification_stop(&self, drive: &str) -> Result { + let source = lock(&self.sources)?.get(drive).cloned(); + let Some(source) = source else { return Ok(true) }; + source.stopped.store(true, Ordering::Release); + lock(&source.watcher)?.take(); + if source.busy.load(Ordering::Acquire) + || source.flush_busy.load(Ordering::Acquire) + || source.active_queries.load(Ordering::Acquire) != 0 + { + return Ok(false); + } + source.close_idle(&self.budget)?; + lock(&self.sources)?.remove(drive); + Ok(true) + } + + pub(super) fn storage_directory(&self) -> &Path { + &self.budget.namespace + } + + /// Call inside the blocking worker, not in the async future that waits for it. + pub(super) fn run_observed_operation(&self, drive: &str, root: &Path, path: &Path, operation: impl FnOnce() -> T) -> T { + self.run_observed_mutation(drive, root, path, MutationScope::Parent, operation) + } + + pub(super) fn run_observed_subtree_operation(&self, drive: &str, root: &Path, path: &Path, operation: impl FnOnce() -> T) -> T { + self.run_observed_mutation(drive, root, path, MutationScope::Subtree, operation) + } + + pub(super) fn run_observed_parent_creation(&self, drive: &str, root: &Path, path: &Path, operation: impl FnOnce() -> T) -> T { + self.run_observed_mutation(drive, root, path, MutationScope::Ancestors, operation) + } + + pub(super) fn run_observed_root_creation(&self, root: &Path, path: &Path, operation: impl FnOnce() -> T) -> T { + let bindings = lock(&self.sources).map(|sources| { + sources + .iter() + .filter(|(_, source)| source.root_path == root && !source.stopped.load(Ordering::Acquire)) + .map(|(drive, _)| drive.clone()) + .collect::>() + }); + let _observations = match bindings { + Ok(bindings) => bindings + .iter() + .filter_map(|drive| self.mutation_observation(drive, root, path, MutationScope::Ancestors)) + .collect::>(), + Err(_) => { + log::warn!("Archive directory observation is unavailable. Restart the companion and retry verification."); + Vec::new() + } + }; + operation() + } + + fn run_observed_mutation(&self, drive: &str, root: &Path, path: &Path, scope: MutationScope, operation: impl FnOnce() -> T) -> T { + let _observation = self.mutation_observation(drive, root, path, scope); + operation() + } + + fn mutation_observation(&self, drive: &str, root: &Path, path: &Path, scope: MutationScope) -> Option { + let observation = (|| { + let source = lock(&self.sources)?.get(drive).cloned(); + let Some(source) = source.filter(|source| source.root_path == root && !source.stopped.load(Ordering::Acquire)) else { + return Ok(None); + }; + let prepare = (|| { + let relative = path + .strip_prefix(root) + .ok() + .and_then(|path| path.to_str()) + .ok_or(IndexError::Invalid( + "Directory mutation is outside its bound source or uses unsupported encoding.", + ))?; + #[cfg(windows)] + let relative = relative.replace('\\', "/"); + let relative = relative.to_owned(); + if !relative.is_empty() || scope != MutationScope::Ancestors { + validate_directory_path(&relative)?; + } + if relative.len() > limit("max_observation_bytes")? { + return Err(IndexError::Invalid("Directory mutation path exceeds the observation byte limit.")); + } + Ok(MutationObservation { + read: source.begin_read()?, + parent: relative.rsplit_once('/').map_or("", |(parent, _)| parent).to_owned(), + subtree: (scope == MutationScope::Subtree).then_some(relative), + ancestors: scope == MutationScope::Ancestors, + }) + })(); + match prepare { + Ok(observation) => { + observation.observe(); + Ok(Some(observation)) + } + Err(error) => { + source.invalidate()?; + Err(error) + } + } + })(); + match observation { + Ok(observation) => observation, + Err(_) => { + log::warn!("Directory mutation observation is unavailable. Check drive access and retry verification."); + None + } + } + } + + pub fn apply_policy(&self, policy: DirectoryPolicy) -> Result<(), IndexError> { + self.apply_policy_with_persistence(policy, || Ok(())) + } + + pub(super) fn apply_policy_with_persistence( + &self, + policy: DirectoryPolicy, + persist: impl FnOnce() -> Result<(), IndexError>, + ) -> Result<(), IndexError> { + policy.validate()?; + let _policy_gate = lock(&self.budget.policy_gate)?; + let sources: Vec<_> = lock(&self.sources)?.values().cloned().collect(); + let mut writers: Vec<_> = sources.iter().map(|source| lock(&source.writer)).collect::>()?; + let mut readers: Vec<_> = sources.iter().map(|source| lock(&source.reader)).collect::>()?; + let mut state = lock(&self.budget.state)?; + let previous_targets: Vec<_> = writers + .iter() + .map(|writer| writer.as_ref().map(DirectoryIndex::page_cache_target_kib)) + .collect(); + let outcome = (|| { + for writer in &mut writers { + if let Some(index) = writer.as_mut() { + index.configure_page_cache(policy.page_cache_kib)?; + } + } + persist() + })(); + if let Err(error) = outcome { + let mut restored = true; + for (writer, previous_target) in writers.iter_mut().zip(previous_targets) { + if let (Some(index), Some(target)) = (writer.as_mut(), previous_target) { + restored &= index.configure_page_cache(target).is_ok(); + } + } + if !restored { + for (source, reader) in sources.iter().zip(&mut readers) { + source.stopped.store(true, Ordering::Release); + **reader = None; + } + return Err(IndexError::Invalid( + "Directory policy could not be restored. Sources are stopped. Restart the companion.", + )); + } + return Err(error); + } + for (writer, reader) in writers.iter().zip(&mut readers) { + **reader = writer.as_ref().map(DirectoryIndex::reader); + } + state.policy = policy; + state.next_cleanup_at = Instant::now(); + drop(state); + drop(readers); + drop(writers); + for source in &sources { + if let Ok(mut status) = source.status.lock() { + status.retry_at = Instant::now(); + status.failure_delay = Duration::ZERO; + } + } + Ok(()) + } + + pub fn new(namespace: PathBuf) -> Result, IndexError> { + let manager = Arc::new(Self { + sources: Mutex::new(HashMap::new()), + budget: Arc::new(DiskBudget { + namespace, + policy_gate: Mutex::new(()), + space_probe: available_disk_bytes, + state: Mutex::new(DiskState { + policy: DirectoryPolicy::defaults()?, + reserved: 0, + next_cleanup_at: Instant::now(), + }), + }), + scans: Arc::new(Semaphore::new(limit("scan_workers")?)), + queries: Arc::new(Semaphore::new(limit("query_workers")?)), + updates: Arc::new(Semaphore::new(limit("query_workers")?)), + }); + let weak = Arc::downgrade(&manager); + tauri::async_runtime::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_millis(limit("event_coalescing_ms").unwrap_or(300) as u64)); + let mut maintenance_failed = false; + interval.tick().await; + loop { + interval.tick().await; + let Some(manager) = weak.upgrade() else { + break; + }; + if manager.tick().is_err() { + if !maintenance_failed { + log::warn!("event=directory_maintenance_unavailable Directory maintenance paused. Check private local storage."); + } + maintenance_failed = true; + } else if maintenance_failed { + log::info!("event=directory_maintenance_recovered"); + maintenance_failed = false; + } + } + }); + Ok(manager) + } + + fn start_scan(self: &Arc, source: &Arc) -> Result<(), IndexError> { + if source.capability_failed.load(Ordering::Acquire) || source.media_unavailable.load(Ordering::Acquire) { + return Ok(()); + } + let mut rebind_only = false; + if lock(&source.next_full_pass)?.is_some_and(|deadline| Instant::now() < deadline) { + let writer = lock(&source.writer)?; + if !writer + .as_ref() + .map(DirectoryIndex::has_resumable_scan) + .transpose()? + .unwrap_or(false) + { + if lock(&source.root)?.is_some() { + return Ok(()); + } + rebind_only = true; + } + } + if source.stopped.load(Ordering::Acquire) + || Instant::now() < lock(&source.status)?.retry_at + || source + .busy + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Ok(()); + } + let Ok(permit) = Arc::clone(&self.scans).try_acquire_owned() else { + source.busy.store(false, Ordering::Release); + return Ok(()); + }; + { + let mut status = lock(&source.status)?; + status.state = if lock(&source.reader)?.is_some() { "updating" } else { "building" }; + status.stalled = false; + } + source.progress()?; + if !rebind_only + && !lock(&source.writer)? + .as_ref() + .map(DirectoryIndex::has_resumable_scan) + .transpose()? + .unwrap_or(false) + { + *lock(&source.next_full_pass)? = Some(Instant::now() + Duration::from_secs(limit("minimum_full_pass_spacing_seconds")? as u64)); + } + let ownership = ScanOwnership { + source: Arc::clone(source), + _permit: permit, + }; + let source = Arc::clone(source); + let budget = Arc::clone(&self.budget); + tauri::async_runtime::spawn(async move { + let task_source = Arc::clone(&source); + let task_budget = Arc::clone(&budget); + let result = tokio::task::spawn_blocking(move || { + let _ownership = ownership; + let outcome = task_source.scan_with_mode(&task_budget, rebind_only); + if let Err(error) = &outcome { + task_source.fail(error, &task_budget); + } + outcome + }) + .await; + if result.is_err() { + source.fail( + &IndexError::Invalid("Directory worker failed. Restart the companion and retry."), + &budget, + ); + } + }); + Ok(()) + } + + fn tick(self: &Arc) -> Result<(), IndexError> { + let policy = self.budget.policy()?; + let mut retired = Vec::new(); + let sources: Vec<_> = { + let mut sources = lock(&self.sources)?; + sources.retain(|_, source| { + let expired = source + .status + .lock() + .map(|status| status.accessed.elapsed() >= Duration::from_secs(limit("idle_release_seconds").unwrap_or(600) as u64)) + .unwrap_or(false); + if expired + && !source.capability_failed.load(Ordering::Acquire) + && !source.busy.load(Ordering::Acquire) + && !source.flush_busy.load(Ordering::Acquire) + && Arc::strong_count(source) == 1 + { + source.stopped.store(true, Ordering::Release); + retired.push(Arc::clone(source)); + return false; + } + true + }); + sources.values().cloned().collect() + }; + for source in retired { + let budget = Arc::clone(&self.budget); + tauri::async_runtime::spawn(async move { + let result = tokio::task::spawn_blocking(move || { + let outcome = source.close_idle(&budget); + drop(source); + lock(&budget.state)?.next_cleanup_at = Instant::now(); + outcome + }) + .await; + if !matches!(result, Ok(Ok(()))) { + log::warn!("Directory idle close failed. Cache ownership remains protected until its worker exits."); + } + }); + } + for source in sources { + if source.capability_failed.load(Ordering::Acquire) || source.media_unavailable.load(Ordering::Acquire) { + continue; + } + let stalled = { + let status = lock(&source.status)?; + source.busy.load(Ordering::Acquire) && !status.stalled && status.progress.elapsed() >= STALLED_READ_TIMEOUT + }; + if stalled { + source.invalidate()?; + let mut status = lock(&source.status)?; + status.stalled = true; + status.state = "ready"; + status.message = Some( + "Directory read has stalled. Existing results remain available; scan capacity stays reserved until the worker exits." + .to_owned(), + ); + } + if Instant::now() < lock(&source.status)?.retry_at { + continue; + } + if source + .flush_busy + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + if let Ok(permit) = Arc::clone(&self.updates).try_acquire_owned() { + let task_source = Arc::clone(&source); + let ownership = UpdateOwnership { + source: Arc::clone(&source), + _permit: permit, + }; + let budget = Arc::clone(&self.budget); + let scans = Arc::clone(&self.scans); + tauri::async_runtime::spawn(async move { + let result = tokio::task::spawn_blocking(move || { + let _ownership = ownership; + let result = task_source + .recover_watcher() + .and_then(|()| task_source.flush(&budget)) + .and_then(|()| task_source.reconcile(&budget, &scans)); + if let Err(error) = &result { + task_source.fail(error, &budget); + } + result + }) + .await; + if result.is_err() { + log::warn!("Directory update worker failed. Restart the companion."); + } + }); + } else { + source.flush_busy.store(false, Ordering::Release); + } + } + let required = lock(&source.observations)?.verification_required || source.watcher_failed.load(Ordering::Acquire); + let status = lock(&source.status)?; + let interval = if required { + policy.degraded_verification_seconds + } else { + policy.healthy_verification_seconds + }; + let due = status.verification_due( + Instant::now(), + Duration::from_secs(interval as u64), + lock(&source.reader)?.is_some(), + ); + drop(status); + let resumable = lock(&source.writer)? + .as_ref() + .map(DirectoryIndex::has_resumable_scan) + .transpose()? + .unwrap_or(false); + if due || resumable { + self.start_scan(&source)?; + } + } + Ok(()) + } + + pub async fn search( + self: &Arc, + drive: String, + root: PathBuf, + query: String, + include_dot: bool, + ) -> Result { + search_terms(&query)?; + let evidence_root = root.clone(); + let evidence = tokio::task::spawn_blocking(move || volume_evidence(&evidence_root)) + .await + .map_err(|_| IndexError::Invalid("Drive evidence worker failed. Retry the search."))??; + let source = { + let mut sources = lock(&self.sources)?; + if let Some(previous) = sources.get(&drive) { + let change = lock(&previous.volume)?.compare(&evidence); + if change == VolumeChange::Unavailable { + return Err(std::io::Error::new( + std::io::ErrorKind::NotConnected, + "Directory volume is not mounted. Mount the volume to resume search.", + ) + .into()); + } + if change == VolumeChange::Generation { + if !previous.busy.load(Ordering::Acquire) && !previous.flush_busy.load(Ordering::Acquire) { + previous.invalidate()?; + self.start_scan(previous)?; + } + return Err(IndexError::QueryBusy); + } + if previous.root_path != root || change == VolumeChange::Index { + if previous.busy.load(Ordering::Acquire) + || previous.flush_busy.load(Ordering::Acquire) + || previous.active_queries.load(Ordering::Acquire) != 0 + { + previous.invalidate()?; + return Err(IndexError::QueryBusy); + } + previous.stopped.store(true, Ordering::Release); + previous.close_idle(&self.budget)?; + sources.remove(&drive); + } + } + Arc::clone( + sources + .entry(drive.clone()) + .or_insert_with(|| LocalSource::with_volume(root, &self.budget.namespace, evidence)), + ) + }; + source.touch()?; + if source.capability_failed.load(Ordering::Acquire) { + return Err(IndexError::Invalid(MOUNT_BOUNDARY_CAPABILITY_ERROR)); + } + if source.media_unavailable.load(Ordering::Acquire) { + if Instant::now() < lock(&source.status)?.retry_at { + return Err(std::io::Error::new( + std::io::ErrorKind::NotConnected, + "Directory volume is temporarily unavailable. Insert or mount the volume and retry after the pause.", + ) + .into()); + } + source.media_unavailable.store(false, Ordering::Release); + #[cfg(windows)] + lock(&source.removal_watch)?.take(); + } + self.start_scan_if_cold(&source)?; + let reader = lock(&source.reader)?.clone(); + let Some(reader) = reader else { + let status = lock(&source.status)?; + return Ok(DirectorySearchResult { + results: Vec::new(), + total_matches: 0, + cache_state: status.state.into(), + directory_count: 0, + search_contract_version: cfg!(any(target_os = "linux", target_os = "windows")).then_some(SEARCH_CONTRACT_VERSION), + match_ranks: HashMap::new(), + matched_term_counts: HashMap::new(), + accepted_revision: Some(0), + coverage: Some("partial".into()), + coverage_issue_count: 0, + last_verified_at_ms: None, + status_message: status.message.clone(), + monitoring_state: Some( + if source.watcher_failed.load(Ordering::Acquire) { + "unavailable" + } else { + "starting" + } + .into(), + ), + retry_after_ms: status.retry_after_ms(), + }); + }; + let permit = Arc::clone(&self.queries).try_acquire_owned().map_err(|_| IndexError::QueryBusy)?; + let cancellation = QueryCancellation(Arc::new(AtomicBool::new(false))); + let cancelled = Arc::clone(&cancellation.0); + let task_source = Arc::clone(&source); + source.active_queries.fetch_add(1, Ordering::AcqRel); + let ownership = QueryOwnership { + source: Arc::clone(&source), + _permit: permit, + }; + let result = tokio::task::spawn_blocking(move || { + let _ownership = ownership; + let observation = task_source.begin_read()?; + task_source.ensure_available()?; + let root = lock(&task_source.root)?.as_ref().ok_or(IndexError::QueryBusy)?.clone(); + if let Err(error) = root.verify() { + task_source.root_invalid.store(true, Ordering::Release); + return Err(error); + } + let response = reader.search(&query, DEFAULT_RESULT_LIMIT, include_dot, &cancelled, false)?; + task_source.ensure_available()?; + if let Err(error) = root.verify() { + task_source.root_invalid.store(true, Ordering::Release); + return Err(error); + } + if !lock(&task_source.observations)?.is_current(observation.token, "") { + return Err(IndexError::Interrupted); + } + Ok(response) + }) + .await + .map_err(|_| IndexError::Invalid("Directory query worker failed. Retry the search."))?; + if source.root_invalid.swap(false, Ordering::AcqRel) { + source.invalidate()?; + lock(&source.root)?.take(); + lock(&source.watcher)?.take(); + self.start_scan(&source)?; + } + let result = result?; + source.ensure_available()?; + let status = lock(&source.status)?; + let wire_count = |value: i64| -> Result { + if !(0..=MAX_WIRE_INTEGER).contains(&value) { + return Err(IndexError::Invalid("Directory response integer exceeds the supported API range.")); + } + usize::try_from(value).map_err(|_| IndexError::Invalid("Directory count exceeds the supported API range.")) + }; + wire_count(result.accepted_revision)?; + if let Some(verified) = result.last_verified_at_ms { + wire_count(verified)?; + } + Ok(DirectorySearchResult { + match_ranks: result.results.iter().map(|row| (row.path.clone(), row.match_rank)).collect(), + matched_term_counts: result + .results + .iter() + .map(|row| (row.path.clone(), row.matched_term_count)) + .collect(), + results: result.results.into_iter().map(|row| row.path).collect(), + total_matches: wire_count(result.total_matches)?, + directory_count: wire_count(result.directory_count)?, + cache_state: status.state.into(), + search_contract_version: cfg!(any(target_os = "linux", target_os = "windows")).then_some(SEARCH_CONTRACT_VERSION), + accepted_revision: Some(result.accepted_revision), + coverage: Some(result.coverage), + coverage_issue_count: wire_count(result.issue_count)?, + last_verified_at_ms: result.last_verified_at_ms, + status_message: status.message.clone(), + monitoring_state: Some( + if source.watcher_failed.load(Ordering::Acquire) { + "unavailable" + } else if source.watcher_reconciling.load(Ordering::Acquire) { + "recovering" + } else { + "healthy" + } + .into(), + ), + retry_after_ms: status.retry_after_ms(), + }) + } + + fn start_scan_if_cold(self: &Arc, source: &Arc) -> Result<(), IndexError> { + if (lock(&source.reader)?.is_none() || lock(&source.root)?.is_none()) && Instant::now() >= lock(&source.status)?.retry_at { + self.start_scan(source)?; + } + Ok(()) + } +} + +impl Drop for LocalDirectoryManager { + fn drop(&mut self) { + if let Ok(sources) = self.sources.lock() { + for source in sources.values() { + source.stopped.store(true, Ordering::Release); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(target_os = "linux")] + #[test] + fn rebound_candidate_revalidates_completed_branches() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("root"); + std::fs::create_dir_all(path.join("old")).unwrap(); + std::fs::create_dir(path.join("later")).unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("private")).unwrap(); + let source = LocalSource::new(path.clone(), manager.storage_directory()); + let root = source.bind_root(&manager.budget).unwrap(); + let candidate = { + let mut writer = lock(&source.writer).unwrap(); + let index = writer.as_mut().unwrap(); + index.write_paths(["accepted".to_owned()], None).unwrap(); + index.finish_generation(1, 0).unwrap(); + let (candidate, selected) = index.prepare_scan().unwrap(); + assert!(!selected); + index.seed_scan(candidate).unwrap(); + candidate + }; + source.flush(&manager.budget).unwrap(); + let baseline = source.begin_read().unwrap(); + lock(&source.observations).unwrap().scan_epoch = Some((candidate, baseline.token.epoch)); + source.scan_parent(&root, candidate, "", &manager.budget).unwrap(); + source.scan_parent(&root, candidate, "old", &manager.budget).unwrap(); + let interrupted = source.scan_to_acceptance_with(&root, baseline.token, candidate, &manager.budget, |generation, parent| { + if parent == "later" { + return Err(IndexError::Interrupted); + } + source.scan_parent(&root, generation, parent, &manager.budget) + }); + assert!(matches!(interrupted, Err(IndexError::Interrupted))); + assert_eq!( + lock(&source.writer) + .unwrap() + .as_ref() + .unwrap() + .pending_scan_paths(candidate, 1) + .unwrap(), + ["later"] + ); + drop(baseline); + source.invalidate().unwrap(); + std::fs::rename(&path, temporary.path().join("previous-root")).unwrap(); + std::fs::create_dir_all(path.join("later")).unwrap(); + std::fs::create_dir(path.join("new")).unwrap(); + source.scan(&manager.budget).unwrap(); + let writer = lock(&source.writer).unwrap(); + let index = writer.as_ref().unwrap(); + assert_eq!(index.accepted_generation().unwrap(), Some(candidate)); + let result = index + .search("old new later", 5, false, &Arc::new(AtomicBool::new(false)), false) + .unwrap(); + let paths: std::collections::BTreeSet<_> = result.results.iter().map(|row| row.path.as_str()).collect(); + assert_eq!(paths, ["later", "new"].into_iter().collect()); + assert_eq!(result.coverage, "complete"); + assert!(result.last_verified_at_ms.is_some()); + } + + #[test] + fn volume_swaps_and_class_changes_choose_the_retained_boundary_index() { + use super::super::models::DriveType; + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("root"); + std::fs::create_dir(&root).unwrap(); + let namespace = temporary.path().join("private"); + let first = VolumeEvidence { + class: DriveType::Fixed, + volume: Some("first".into()), + ..Default::default() + }; + let source = LocalSource::with_volume(root.clone(), &namespace, first.clone()); + let first_path = source.index_path.clone(); + let identity = json!({"platform": std::env::consts::OS, "root": hex::encode(root.as_os_str().as_encoded_bytes())}); + let mut index = DirectoryIndex::open(&first_path, SourceKind::Local, &identity).unwrap(); + index.bind_volume(&first).unwrap(); + drop(index); + for class in [DriveType::Removable, DriveType::Unknown, DriveType::Network, DriveType::Virtual] { + let source = LocalSource::with_volume(root.clone(), &namespace, VolumeEvidence { class, ..first.clone() }); + assert_eq!(source.index_path, first_path); + } + let second = VolumeEvidence { + volume: Some("second".into()), + ..first.clone() + }; + let second_source = LocalSource::with_volume(root.clone(), &namespace, second.clone()); + assert_ne!(second_source.index_path, first_path); + let mut index = DirectoryIndex::open(&second_source.index_path, SourceKind::Local, &identity).unwrap(); + index.bind_volume(&second).unwrap(); + drop(index); + assert_eq!(LocalSource::with_volume(root, &namespace, first).index_path, first_path); + } + + #[cfg(target_os = "linux")] + #[test] + fn unmounted_retained_root_does_not_index_or_prune_the_mount_point() { + use super::super::models::DriveType; + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("mount-point"); + std::fs::create_dir(&root).unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("private")).unwrap(); + let mounted = VolumeEvidence { + class: DriveType::Fixed, + volume: Some("mounted-volume".into()), + separate_mount: true, + ..Default::default() + }; + let original = LocalSource::with_volume(root.clone(), manager.storage_directory(), mounted.clone()); + let identity = json!({"platform": std::env::consts::OS, "root": hex::encode(root.as_os_str().as_encoded_bytes())}); + let mut index = DirectoryIndex::open(&original.index_path, SourceKind::Local, &identity).unwrap(); + index.bind_volume(&mounted).unwrap(); + index.write_paths(["retained".into()], None).unwrap(); + index.finish_generation(1, 0).unwrap(); + drop(index); + let current = LocalSource::with_volume(root, manager.storage_directory(), VolumeEvidence::default()); + assert_eq!(current.index_path, original.index_path); + assert!(current.scan(&manager.budget).is_err()); + assert!(lock(¤t.writer).unwrap().is_none()); + let index = DirectoryIndex::open(¤t.index_path, SourceKind::Local, &identity).unwrap(); + assert_eq!( + index + .search("retained", 5, false, &Arc::new(AtomicBool::new(false)), false) + .unwrap() + .directory_count, + 1 + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn removable_pause_releases_idle_handles_and_keeps_catalogue() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("root"); + std::fs::create_dir_all(root.join("retained")).unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("private")).unwrap(); + let source = LocalSource::new(root, manager.storage_directory()); + source.scan(&manager.budget).unwrap(); + assert!(source.pause_for_removal().unwrap()); + assert!(lock(&source.root).unwrap().is_none()); + assert!(lock(&source.watcher).unwrap().is_none()); + assert!(source.ensure_available().is_err()); + let reader = lock(&source.reader).unwrap().as_ref().unwrap().clone(); + assert_eq!( + reader + .search("retained", 5, false, &Arc::new(AtomicBool::new(false)), false) + .unwrap() + .directory_count, + 1 + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn restart_reuses_boundary_index_without_file_identity_or_session_metadata() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("root"); + std::fs::create_dir_all(root.join("retained")).unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("private")).unwrap(); + let first = LocalSource::new(root.clone(), manager.storage_directory()); + first.scan(&manager.budget).unwrap(); + let filename = first.index_path.clone(); + first.close_idle(&manager.budget).unwrap(); + std::fs::rename(&root, temporary.path().join("old-root")).unwrap(); + std::fs::create_dir_all(root.join("current")).unwrap(); + let second = LocalSource::new(root, manager.storage_directory()); + assert_eq!(second.index_path, filename); + assert_ne!(first.session, second.session); + let mut index = DirectoryIndex::open( + &filename, + SourceKind::Local, + &json!({"platform": std::env::consts::OS, "root": hex::encode(second.root_path.as_os_str().as_encoded_bytes())}), + ) + .unwrap(); + let cancelled = Arc::new(AtomicBool::new(false)); + assert_eq!(index.search("retained", 5, false, &cancelled, false).unwrap().directory_count, 1); + index.mark_uncertain().unwrap(); + assert_eq!(index.search("retained", 5, false, &cancelled, false).unwrap().coverage, "uncertain"); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn background_tick_resumes_interrupted_initial_scan_before_verification_interval() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("root"); + std::fs::create_dir_all(root.join("retained")).unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("private")).unwrap(); + let source = LocalSource::new(root, manager.storage_directory()); + source.bind_root(&manager.budget).unwrap(); + { + let mut writer = lock(&source.writer).unwrap(); + let index = writer.as_mut().unwrap(); + let (generation, _) = index.prepare_scan().unwrap(); + index.seed_scan(generation).unwrap(); + } + source.fail(&IndexError::Interrupted, &manager.budget); + let next_full_pass = Instant::now() + Duration::from_secs(limit("minimum_full_pass_spacing_seconds").unwrap() as u64); + *lock(&source.next_full_pass).unwrap() = Some(next_full_pass); + lock(&manager.sources) + .unwrap() + .insert(source.source_id.clone(), Arc::clone(&source)); + + manager.tick().unwrap(); + let _scan_exit = manager.scans.acquire().await.unwrap(); + let writer = lock(&source.writer).unwrap(); + let index = writer.as_ref().unwrap(); + assert!(index.accepted_generation().unwrap().is_some()); + let result = index + .search("retained", 5, false, &Arc::new(AtomicBool::new(false)), false) + .unwrap(); + assert_eq!(result.total_matches, 1); + assert!(result.last_verified_at_ms.is_some()); + assert_eq!(*lock(&source.next_full_pass).unwrap(), Some(next_full_pass)); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn repeated_automatic_requests_do_not_start_full_passes_inside_spacing() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("root"); + std::fs::create_dir_all(root.join("retained")).unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("private")).unwrap(); + let source = LocalSource::new(root, manager.storage_directory()); + source.scan(&manager.budget).unwrap(); + *lock(&source.next_full_pass).unwrap() = + Some(Instant::now() + Duration::from_secs(limit("minimum_full_pass_spacing_seconds").unwrap() as u64)); + let permits = manager.scans.available_permits(); + for _attempt in 0..10 { + manager.start_scan(&source).unwrap(); + } + assert!(!source.busy.load(Ordering::Acquire)); + assert_eq!(manager.scans.available_permits(), permits); + } + + #[cfg(feature = "recovery-verification")] + #[tokio::test] + async fn verification_controls_reconcile_gap_and_release_resources() { + const DEADLINE: Duration = Duration::from_secs(10); + const POLL_INTERVAL: Duration = Duration::from_millis(20); + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("source"); + std::fs::create_dir_all(root.join("retained-reports")).unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("private")).unwrap(); + let search = || async { + loop { + match manager.search("verification".into(), root.clone(), "reports".into(), false).await { + Err(IndexError::Interrupted | IndexError::QueryBusy) => tokio::time::sleep(POLL_INTERVAL).await, + result => break result, + } + } + }; + tokio::time::timeout(DEADLINE, async { + loop { + let response = search().await.unwrap(); + if response.monitoring_state.as_deref() == Some("healthy") && response.results.contains(&"retained-reports".into()) { + break; + } + tokio::time::sleep(POLL_INTERVAL).await; + } + manager.verification_watcher_loss("verification", false).unwrap(); + let retained = search().await.unwrap(); + assert_eq!(retained.monitoring_state.as_deref(), Some("unavailable")); + assert!(retained.results.contains(&"retained-reports".into())); + std::fs::create_dir(root.join("gap-reports")).unwrap(); + manager.verification_watcher_loss("verification", true).unwrap(); + { + let source = lock(&manager.sources).unwrap().get("verification").unwrap().clone(); + *lock(&source.next_full_pass).unwrap() = Some(Instant::now()); + } + loop { + let response = search().await.unwrap(); + if response.monitoring_state.as_deref() == Some("healthy") && response.coverage.as_deref() == Some("complete") { + assert!(response.results.contains(&"gap-reports".into())); + break; + } + tokio::time::sleep(POLL_INTERVAL).await; + } + while !manager.verification_stop("verification").unwrap() { + tokio::time::sleep(POLL_INTERVAL).await; + } + assert!(lock(&manager.sources).unwrap().is_empty()); + assert_eq!(manager.scans.available_permits(), limit("scan_workers").unwrap()); + assert_eq!(manager.updates.available_permits(), limit("query_workers").unwrap()); + }) + .await + .expect("bounded verification lifecycle"); + } + + #[tokio::test] + async fn released_root_returns_query_busy_until_rebound_without_losing_rows() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("root"); + std::fs::create_dir_all(root.join("retained-reports")).unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("private")).unwrap(); + let source = LocalSource::new(root.clone(), manager.storage_directory()); + lock(&source.status).unwrap().watcher_retry_at = Instant::now() + FAILURE_RETRY_MAX; + source.scan(&manager.budget).unwrap(); + let reader = lock(&source.reader).unwrap().as_ref().unwrap().clone(); + let cancelled = Arc::new(AtomicBool::new(false)); + let before = reader.search("reports", 3, false, &cancelled, false).unwrap(); + lock(&source.root).unwrap().take(); + source.busy.store(true, Ordering::Release); + lock(&manager.sources).unwrap().insert("fixture".into(), Arc::clone(&source)); + assert!(matches!( + manager.search("fixture".into(), root.clone(), "reports".into(), false).await, + Err(IndexError::QueryBusy) + )); + assert_eq!(reader.search("reports", 3, false, &cancelled, false).unwrap(), before); + source.bind_root(&manager.budget).unwrap(); + source.busy.store(false, Ordering::Release); + let recovered = manager.search("fixture".into(), root, "reports".into(), false).await.unwrap(); + assert_eq!(recovered.results, ["retained-reports"]); + assert_eq!(recovered.last_verified_at_ms, before.last_verified_at_ms); + } + + #[test] + fn watcher_loss_is_coalesced_until_recovery_with_opaque_context() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("private-root"); + let source = LocalSource::new(root.clone(), temporary.path()); + let replacement = LocalSource::new(root, temporary.path()); + assert_eq!(source.source_id, replacement.source_id); + assert_ne!(source.session, replacement.session); + assert!(source.source_id.chars().all(|value| value.is_ascii_hexdigit())); + assert!(source.lose_watcher().unwrap()); + for _attempt in 0..10 { + assert!(!source.lose_watcher().unwrap()); + } + assert!(lock(&source.observations).unwrap().verification_required); + source.watcher_failed.store(false, Ordering::Release); + assert!(source.lose_watcher().unwrap()); + } + + #[test] + fn watcher_startup_failure_retries_without_reinitializing_writer() { + let temporary = tempfile::tempdir().unwrap(); + let root_path = temporary.path().join("root"); + let namespace = temporary.path().join("private"); + let source = LocalSource::new(root_path.clone(), &namespace); + let budget = Arc::new(DiskBudget { + namespace, + policy_gate: Mutex::new(()), + state: Mutex::new(DiskState { + policy: DirectoryPolicy::defaults().unwrap(), + reserved: 0, + next_cleanup_at: Instant::now(), + }), + space_probe: available_disk_bytes, + }); + source.recover_watcher().unwrap(); + assert!(source.watcher_failed.load(Ordering::Acquire)); + let fallback = source.begin_read().unwrap(); + for _attempt in 0..10 { + lock(&source.status).unwrap().watcher_retry_at = Instant::now(); + source.recover_watcher().unwrap(); + assert!(source.watcher_failed.load(Ordering::Acquire)); + assert!(lock(&source.observations).unwrap().is_current(fallback.token, "")); + } + drop(fallback); + let retry = lock(&source.status).unwrap().watcher_retry_at; + assert!(retry > Instant::now()); + std::fs::create_dir_all(root_path.join("reports")).unwrap(); + source.scan(&budget).unwrap(); + assert!(lock(&source.writer).unwrap().is_some()); + assert!(lock(&source.watcher).unwrap().is_none()); + assert!(lock(&source.status) + .unwrap() + .message + .as_ref() + .unwrap() + .contains("monitoring is unavailable")); + assert_eq!(lock(&source.status).unwrap().watcher_retry_at, retry); + let fallback = source.begin_read().unwrap(); + lock(&source.status).unwrap().watcher_retry_at = Instant::now(); + source.recover_watcher().unwrap(); + assert!(lock(&source.watcher).unwrap().is_some()); + assert!(lock(&source.observations).unwrap().verification_required); + assert!(!lock(&source.observations).unwrap().is_current(fallback.token, "")); + drop(fallback); + source.scan(&budget).unwrap(); + assert!(lock(&source.status).unwrap().message.is_none()); + source.observe(Err(notify::Error::generic("injected native failure"))).unwrap(); + assert!(source.watcher_failed.load(Ordering::Acquire)); + assert!(lock(&source.observations).unwrap().verification_required); + source.recover_watcher().unwrap(); + source.scan(&budget).unwrap(); + assert!(!source.watcher_failed.load(Ordering::Acquire)); + source.stopped.store(true, Ordering::Release); + lock(&source.watcher).unwrap().take(); + source.recover_watcher().unwrap(); + assert!(lock(&source.watcher).unwrap().is_none()); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + #[ignore = "Explicit small-tree native watcher timing measurement; not part of routine tests."] + async fn small_many_file_tree_reports_first_result_and_native_watcher_freshness() { + const FRESHNESS_TARGET: Duration = Duration::from_secs(2); + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("root"); + std::fs::create_dir(&root).unwrap(); + let regular_files = limit("row_batch_size").unwrap() * 2 + 1; + for number in 0..regular_files { + std::fs::write(root.join(format!("file-{number:04}")), b"file").unwrap(); + } + std::fs::create_dir(root.join("Reports")).unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("private")).unwrap(); + let poll_period = Duration::from_millis(limit("event_coalescing_ms").unwrap() as u64); + let timeout = Duration::from_secs(limit("query_timeout_seconds").unwrap() as u64); + let mut polling = tokio::time::interval(poll_period); + let started = Instant::now(); + let mut first_result = None; + let before = tokio::time::timeout(timeout, async { + loop { + polling.tick().await; + let response = manager + .search("measurement".into(), root.clone(), "reports".into(), false) + .await + .unwrap(); + if !response.results.is_empty() { + first_result.get_or_insert_with(|| started.elapsed()); + } + if response.cache_state == "ready" && response.directory_count == 1 { + break response; + } + } + }) + .await + .expect("The native many-file build must finish before the query timeout."); + let ready = started.elapsed(); + let source = lock(&manager.sources).unwrap().get("measurement").unwrap().clone(); + assert!(lock(&source.watcher).unwrap().is_some()); + let mutated = Instant::now(); + std::fs::create_dir_all(root.join("Reports New/Child")).unwrap(); + let after = tokio::time::timeout(timeout, async { + loop { + polling.tick().await; + let response = manager + .search("measurement".into(), root.clone(), "reports".into(), false) + .await + .unwrap(); + if response.directory_count == 3 && response.results.iter().any(|path| path == "Reports New/Child") { + break response; + } + } + }) + .await; + let freshness = mutated.elapsed(); + lock(&manager.sources).unwrap().remove("measurement"); + source.stopped.store(true, Ordering::Release); + let _scan_exit = manager.scans.acquire().await.unwrap(); + let _update_exit = manager.updates.acquire_many(limit("query_workers").unwrap() as u32).await.unwrap(); + let _query_exit = manager.queries.acquire_many(limit("query_workers").unwrap() as u32).await.unwrap(); + source.close_idle(&manager.budget).unwrap(); + let after = after.expect("The real watcher and background scheduler must publish the new subtree before the query timeout."); + assert_eq!(after.total_matches, 3); + assert_eq!(after.last_verified_at_ms, before.last_verified_at_ms); + println!( + "{}", + serde_json::json!({ + "measurement": "small_native_many_file_tree", + "os": std::env::consts::OS, + "regular_files": regular_files, + "initial_directories": before.directory_count, + "final_directories": after.directory_count, + "poll_resolution_ms": poll_period.as_millis(), + "first_result_ms": first_result.unwrap().as_millis(), + "ready_ms": ready.as_millis(), + "mutation_freshness_ms": freshness.as_millis(), + "freshness_target_ms": FRESHNESS_TARGET.as_millis(), + "freshness_target_met": freshness <= FRESHNESS_TARGET, + "native_watcher": true, + "application_mutation_hint": false + }) + ); + } + + #[tokio::test] + async fn cooperative_mutation_interruption_preserves_retry_deadline_and_failure_state() { + let temporary = tempfile::tempdir().unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("private")).unwrap(); + let source = LocalSource::new(temporary.path().join("root"), manager.storage_directory()); + for resource_failure in [false, true] { + if resource_failure { + source.fail(&IndexError::StoragePaused("Injected storage refusal."), &manager.budget); + } + let (deadline, delay, message, verified) = { + let status = lock(&source.status).unwrap(); + (status.retry_at, status.failure_delay, status.message.clone(), status.verified) + }; + for _ in 0..3 { + source.fail(&IndexError::Interrupted, &manager.budget); + let status = lock(&source.status).unwrap(); + assert_eq!(status.state, "ready"); + assert_eq!(status.retry_at, deadline); + assert_eq!(status.failure_delay, delay); + assert_eq!(status.message, message); + assert_eq!(status.verified, verified); + } + } + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn idle_query_invalidates_old_reads_without_bypassing_retry_or_root_revalidation() { + for replaced in [false, true] { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("root"); + std::fs::create_dir_all(root.join("retained")).unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("private")).unwrap(); + let source = LocalSource::new(root.clone(), manager.storage_directory()); + source.scan(&manager.budget).unwrap(); + lock(&source.watcher).unwrap().take(); + let reader = lock(&source.reader).unwrap().as_ref().unwrap().clone(); + let cancelled = Arc::new(AtomicBool::new(false)); + let before = reader.search("retained", 3, false, &cancelled, false).unwrap(); + let old_read = source.begin_read().unwrap(); + source.fail(&IndexError::StoragePaused("Injected storage refusal."), &manager.budget); + let (retry_at, verified) = { + let mut status = lock(&source.status).unwrap(); + status.accessed = Instant::now() - Duration::from_secs(limit("idle_release_seconds").unwrap() as u64); + (status.retry_at, status.verified) + }; + lock(&manager.sources).unwrap().insert("drive".into(), Arc::clone(&source)); + if replaced { + std::fs::rename(&root, temporary.path().join("old-root")).unwrap(); + std::fs::create_dir(&root).unwrap(); + } + let response = manager.search("drive".into(), root, "retained".into(), false).await; + if replaced { + assert!(response.is_err()); + assert!(!source.stopped.load(Ordering::Acquire)); + assert!(Arc::ptr_eq(lock(&manager.sources).unwrap().get("drive").unwrap(), &source)); + } else { + let response = response.unwrap(); + assert_eq!(response.results, ["retained"]); + assert_eq!(response.last_verified_at_ms, before.last_verified_at_ms); + } + { + let observations = lock(&source.observations).unwrap(); + assert!(!observations.is_current(old_read.token, "")); + assert!(observations.verification_required); + assert!(observations.producers.contains_key(&old_read.token.producer)); + } + assert_eq!(lock(&source.status).unwrap().retry_at, retry_at); + assert_eq!(lock(&source.status).unwrap().verified, verified); + assert_eq!(reader.search("retained", 3, false, &cancelled, false).unwrap(), before); + drop(old_read); + assert!(lock(&source.observations).unwrap().producers.is_empty()); + lock(&manager.sources).unwrap().remove("drive"); + source.stopped.store(true, Ordering::Release); + source.close_idle(&manager.budget).unwrap(); + } + } + + #[tokio::test] + async fn application_mutation_rejects_unbound_hints_without_replacing_operation_errors() { + for binding in ["drive", "root", "stopped", "outside", "unavailable"] { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("root"); + let manager = LocalDirectoryManager::new(temporary.path().join("private")).unwrap(); + let source = LocalSource::new(root.clone(), manager.storage_directory()); + lock(&source.status).unwrap().retry_at = Instant::now() + FAILURE_RETRY_MAX; + source.stopped.store(binding == "stopped", Ordering::Release); + lock(&manager.sources).unwrap().insert("drive".into(), Arc::clone(&source)); + let drive = if binding == "drive" { "other-drive" } else { "drive" }; + let operation_root = if binding == "root" { + temporary.path().join("other-root") + } else { + root.clone() + }; + let target = if binding == "outside" { + temporary.path().join("outside") + } else { + root.join("target") + }; + let mut executed = false; + let result = manager.run_observed_operation(drive, &operation_root, &target, || { + executed = true; + Err::<(), _>(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "Original operation failure.", + )) + }); + assert!(executed); + assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::PermissionDenied); + let observations = lock(&source.observations).unwrap(); + assert!(observations.pending.is_empty()); + assert!(observations.producers.is_empty()); + assert_eq!(observations.verification_required, matches!(binding, "outside" | "unavailable")); + } + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn application_mutation_reconciles_after_success_or_failure_without_watcher_events() { + for (failed, operation) in [ + (false, "delete"), + (true, "delete"), + (false, "create"), + (true, "create"), + (false, "create-parents"), + (true, "create-parents"), + (false, "archive-parents"), + (true, "archive-parents"), + (false, "rename"), + (true, "rename"), + (false, "recursive-delete"), + (true, "recursive-delete"), + ] { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("root"); + let target = root.join("parent/target"); + let create_parents = matches!(operation, "create-parents" | "archive-parents"); + let create = operation == "create" || create_parents; + std::fs::create_dir_all(if create { target.parent().unwrap() } else { &target }).unwrap(); + if operation == "rename" { + std::fs::create_dir(target.join("child")).unwrap(); + } + if operation == "recursive-delete" { + std::fs::create_dir_all(target.join("survivor/removed")).unwrap(); + } + std::fs::create_dir(root.join("outside")).unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("private")).unwrap(); + let source = LocalSource::new(root.clone(), manager.storage_directory()); + source.scan(&manager.budget).unwrap(); + lock(&source.watcher).unwrap().take(); + lock(&source.status).unwrap().retry_at = Instant::now() + FAILURE_RETRY_MAX; + lock(&manager.sources).unwrap().insert("drive".into(), Arc::clone(&source)); + let reader = lock(&source.reader).unwrap().as_ref().unwrap().clone(); + let cancelled = Arc::new(AtomicBool::new(false)); + let before = reader.search("parent target outside", 3, false, &cancelled, false).unwrap(); + let mutation = || { + let observations = lock(&source.observations).unwrap(); + assert_eq!(observations.producers.len(), 1); + let expected_hints = match operation { + "recursive-delete" => 3, + _ if create_parents => 4, + _ => 1, + }; + assert_eq!(observations.pending.len(), expected_hints); + let expected_parent = if create_parents { "parent/target/nested" } else { "parent" }; + assert!(matches!(&observations.pending[0].1, DirectoryMutation::ReadParent { path } if path == expected_parent)); + drop(observations); + if create_parents { + source.flush(&manager.budget).unwrap(); + lock(&source.status).unwrap().retry_at = Instant::now(); + let retry = lock(&source.status).unwrap().retry_at; + let early_read = source.reconcile(&manager.budget, &manager.scans).unwrap_err(); + assert!(matches!(early_read, IndexError::Interrupted)); + source.fail(&early_read, &manager.budget); + assert_eq!(lock(&source.status).unwrap().retry_at, retry); + assert_eq!(lock(&source.status).unwrap().failure_delay, Duration::ZERO); + assert_eq!( + reader.search("parent target outside", 3, false, &cancelled, false).unwrap().results, + before.results + ); + lock(&source.status).unwrap().retry_at = Instant::now() + FAILURE_RETRY_MAX; + } + match operation { + "create" => std::fs::create_dir(&target), + "create-parents" | "archive-parents" => std::fs::create_dir_all(target.join("nested")), + "rename" => std::fs::rename(&target, root.join("parent/renamed")), + "recursive-delete" if failed => std::fs::remove_dir(target.join("survivor/removed")), + "recursive-delete" => std::fs::remove_dir_all(&target), + _ => std::fs::remove_dir(&target), + }?; + if failed { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "Injected failure after mutation.", + )) + } else { + Ok(()) + } + }; + let result = if operation == "recursive-delete" { + manager.run_observed_subtree_operation("drive", &root, &target, mutation) + } else if operation == "create-parents" { + manager.run_observed_parent_creation("drive", &root, &target.join("nested/file"), mutation) + } else if operation == "archive-parents" { + manager.run_observed_root_creation(&root, &target.join("nested/file"), mutation) + } else { + manager.run_observed_operation("drive", &root, &target, mutation) + }; + assert_eq!( + result.as_ref().err().map(std::io::Error::kind), + failed.then_some(std::io::ErrorKind::PermissionDenied) + ); + assert_eq!( + reader.search("parent target outside", 3, false, &cancelled, false).unwrap().results, + before.results + ); + { + let observations = lock(&source.observations).unwrap(); + assert!(observations.producers.is_empty()); + let expected_hints = match operation { + "recursive-delete" => 6, + _ if create_parents => 5, + _ => 2, + }; + assert_eq!(observations.pending.len(), expected_hints); + } + lock(&manager.sources).unwrap().remove("drive"); + lock(&source.status).unwrap().retry_at = Instant::now(); + source.flush(&manager.budget).unwrap(); + source.reconcile(&manager.budget, &manager.scans).unwrap(); + let after = reader.search("parent target outside", 3, false, &cancelled, false).unwrap(); + let expected_count = match operation { + "create" => 3, + _ if create_parents => 4, + "rename" => 4, + "recursive-delete" if failed => 4, + _ => 2, + }; + assert_eq!(after.directory_count, expected_count); + assert_eq!( + after.results.iter().any(|row| row.path == "parent/target"), + create || (operation == "recursive-delete" && failed) + ); + if operation == "rename" { + assert_eq!(reader.search("renamed", 3, false, &cancelled, false).unwrap().total_matches, 2); + assert_eq!(reader.search("target", 3, false, &cancelled, false).unwrap().total_matches, 0); + } + assert_eq!(after.last_verified_at_ms, before.last_verified_at_ms); + assert!(lock(&source.observations).unwrap().verification_required); + assert!(lock(&source.observations).unwrap().pending.is_empty()); + assert_eq!(lock(&manager.budget.state).unwrap().reserved, 0); + } + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn parent_loss_after_identity_read_retains_rows_and_does_not_hide_storage_or_root_failure() { + for scenario in ["renamed-parent", "replaced-by-file", "missing-storage", "replaced-root"] { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("root"); + std::fs::create_dir_all(path.join("target/child")).unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("private")).unwrap(); + let source = LocalSource::new(path.clone(), manager.storage_directory()); + source.scan(&manager.budget).unwrap(); + lock(&source.watcher).unwrap().take(); + let root = lock(&source.root).unwrap().as_ref().unwrap().clone(); + let reader = lock(&source.reader).unwrap().as_ref().unwrap().clone(); + let cancelled = Arc::new(AtomicBool::new(false)); + let before = reader.search("target", 3, false, &cancelled, false).unwrap(); + let retry_at = lock(&source.status).unwrap().retry_at; + let failure_delay = lock(&source.status).unwrap().failure_delay; + let result = source.with_parent_read(&root, "target", |_read| { + root.parent_identity("target")?; + match scenario { + "renamed-parent" | "replaced-by-file" => { + std::fs::rename(path.join("target"), path.join("moved"))?; + if scenario == "replaced-by-file" { + std::fs::write(path.join("target"), b"not a directory")?; + } + } + "replaced-root" => std::fs::rename(&path, temporary.path().join("old-root"))?, + _ => return Err(IndexError::Io(std::io::Error::from(std::io::ErrorKind::NotFound))), + } + root.children("target", |_entry| Ok(())) + }); + let error = result.unwrap_err(); + assert_eq!(reader.search("target", 3, false, &cancelled, false).unwrap(), before); + let observations = lock(&source.observations).unwrap(); + assert!(observations.producers.is_empty()); + if matches!(scenario, "renamed-parent" | "replaced-by-file") { + assert!(matches!(error, IndexError::Interrupted)); + assert!(observations.verification_required); + assert_eq!(observations.pending.len(), 1); + assert!(matches!(&observations.pending.front().unwrap().1, DirectoryMutation::ReadParent { path } if path.is_empty())); + drop(observations); + source.fail(&error, &manager.budget); + let status = lock(&source.status).unwrap(); + assert_eq!(status.retry_at, retry_at); + assert_eq!(status.failure_delay, failure_delay); + } else { + assert!(!matches!(error, IndexError::Interrupted)); + assert!(observations.pending.is_empty()); + } + } + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn application_subtree_queue_overflow_preserves_rows_and_actual_worker_ownership() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("root"); + std::fs::create_dir_all(root.join("target/child")).unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("private")).unwrap(); + let source = LocalSource::new(root.clone(), manager.storage_directory()); + source.scan(&manager.budget).unwrap(); + lock(&source.watcher).unwrap().take(); + lock(&source.status).unwrap().retry_at = Instant::now() + FAILURE_RETRY_MAX; + lock(&manager.sources).unwrap().insert("drive".into(), Arc::clone(&source)); + let reader = lock(&source.reader).unwrap().as_ref().unwrap().clone(); + let cancelled = Arc::new(AtomicBool::new(false)); + let before = reader.search("target", 3, false, &cancelled, false).unwrap(); + let maximum = limit("max_pending_observations").unwrap(); + { + let mut observations = lock(&source.observations).unwrap(); + for _ in 0..maximum - 1 { + assert!(observations + .enqueue( + DirectoryMutation::ReadParent { path: "target".into() }, + maximum, + limit("max_observation_bytes").unwrap() + ) + .unwrap()); + } + } + let old_read = source.begin_read().unwrap(); + let epoch = lock(&source.observations).unwrap().epoch; + let result = manager.run_observed_subtree_operation("drive", &root, &root.join("target"), || { + let observations = lock(&source.observations).unwrap(); + assert!(observations.epoch > epoch); + assert_eq!(observations.pending.len(), maximum); + assert_eq!(observations.producers.len(), 2); + assert!(!observations.is_current(old_read.token, "target")); + assert!(observations.verification_required); + Err::<(), _>(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "Original deletion failure.", + )) + }); + assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::PermissionDenied); + assert_eq!(reader.search("target", 3, false, &cancelled, false).unwrap(), before); + let observations = lock(&source.observations).unwrap(); + assert_eq!(observations.producers.len(), 1); + assert_eq!(observations.pending.len(), maximum); + assert!(observations.epoch > epoch); + assert!(observations.verification_required); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn application_mutation_ownership_survives_cancelled_async_waiter() { + for (scope, root_bound) in [ + (MutationScope::Parent, false), + (MutationScope::Subtree, false), + (MutationScope::Ancestors, false), + (MutationScope::Ancestors, true), + ] { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("root"); + std::fs::create_dir_all(root.join("target")).unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("private")).unwrap(); + let source = LocalSource::new(root.clone(), manager.storage_directory()); + source.scan(&manager.budget).unwrap(); + lock(&source.watcher).unwrap().take(); + lock(&source.status).unwrap().retry_at = Instant::now() + FAILURE_RETRY_MAX; + lock(&manager.sources).unwrap().insert("drive".into(), Arc::clone(&source)); + let task_manager = Arc::clone(&manager); + let (started, ready) = tokio::sync::oneshot::channel(); + let (release, blocked) = std::sync::mpsc::channel::<()>(); + let (finished, complete) = tokio::sync::oneshot::channel(); + let request = tokio::spawn(async move { + tokio::task::spawn_blocking(move || { + let operation = || { + started.send(()).unwrap(); + blocked.recv().unwrap(); + std::fs::remove_dir(root.join("target")) + }; + let result = if root_bound { + task_manager.run_observed_root_creation(&root, &root.join("target"), operation) + } else { + task_manager.run_observed_mutation("drive", &root, &root.join("target"), scope, operation) + }; + finished.send(result).unwrap(); + }) + .await + }); + ready.await.unwrap(); + request.abort(); + assert!(request.await.unwrap_err().is_cancelled()); + { + let observations = lock(&source.observations).unwrap(); + assert_eq!(observations.producers.len(), 1); + assert_eq!(observations.pending.len(), 1); + assert!(!observations.barriers.is_empty()); + } + release.send(()).unwrap(); + complete.await.unwrap().unwrap(); + let observations = lock(&source.observations).unwrap(); + assert!(observations.producers.is_empty()); + assert_eq!(observations.pending.len(), 2); + assert!(observations.verification_required); + } + } + + #[tokio::test] + async fn repeated_scan_requests_respect_resource_retry_deadline() { + let temporary = tempfile::tempdir().unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("cache")).unwrap(); + for initialized in [false, true] { + let source = LocalSource::new(temporary.path().join(format!("root-{initialized}")), manager.storage_directory()); + std::fs::create_dir(&source.root_path).unwrap(); + let mut reader = None; + if initialized { + let mut index = DirectoryIndex::open(&source.index_path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + index.write_paths(["accepted".to_owned()], None).unwrap(); + index.finish_generation(1, 0).unwrap(); + reader = Some(index.reader()); + *lock(&source.reader).unwrap() = reader.clone(); + *lock(&source.writer).unwrap() = Some(index); + } + let cancelled = Arc::new(AtomicBool::new(false)); + let before = reader + .as_ref() + .map(|reader| reader.search("accepted", 3, false, &cancelled, false).unwrap()); + source.fail(&IndexError::StoragePaused("Injected resource refusal."), &manager.budget); + if !initialized { + lock(&manager.sources).unwrap().insert("fixture".into(), Arc::clone(&source)); + let response = manager + .search("fixture".into(), source.root_path.clone(), "accepted".into(), false) + .await + .unwrap(); + let expected = cfg!(any(target_os = "linux", target_os = "windows")).then_some(SEARCH_CONTRACT_VERSION); + assert_eq!(response.search_contract_version, expected); + assert_eq!(serde_json::to_value(&response).unwrap()["search_contract_version"], json!(expected)); + } + let (deadline, delay, message, progress) = { + let status = lock(&source.status).unwrap(); + (status.retry_at, status.failure_delay, status.message.clone(), status.progress) + }; + let permits = manager.scans.available_permits(); + for _ in 0..3 { + manager.start_scan(&source).unwrap(); + manager.start_scan_if_cold(&source).unwrap(); + assert!(!source.busy.load(Ordering::Acquire)); + assert_eq!(manager.scans.available_permits(), permits); + let status = lock(&source.status).unwrap(); + assert_eq!(status.retry_at, deadline); + assert_eq!(status.failure_delay, delay); + assert_eq!(status.message, message); + assert_eq!(status.progress, progress); + assert_eq!(status.state, "ready"); + } + assert!(lock(&source.observations).unwrap().producers.is_empty()); + assert_eq!( + reader + .as_ref() + .map(|reader| reader.search("accepted", 3, false, &cancelled, false).unwrap()), + before + ); + } + } + + #[tokio::test] + async fn failed_policy_persistence_preserves_effective_resource_policy() { + let temporary = tempfile::tempdir().unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("cache")).unwrap(); + let previous = manager.budget.policy().unwrap(); + let source = LocalSource::new(temporary.path().join("root"), manager.storage_directory()); + let mut index = DirectoryIndex::open(&source.index_path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + index.write_paths(["accepted".to_owned()], None).unwrap(); + *source.reader.lock().unwrap() = Some(index.reader()); + *source.writer.lock().unwrap() = Some(index); + manager.sources.lock().unwrap().insert("drive".to_owned(), Arc::clone(&source)); + let mut proposed = previous.clone(); + proposed.page_cache_kib = 64; + assert!(manager + .apply_policy_with_persistence(proposed, || Err(IndexError::Invalid("Injected storage failure."))) + .is_err()); + assert_eq!(manager.budget.policy().unwrap(), previous); + assert_eq!( + source.writer.lock().unwrap().as_ref().unwrap().page_cache_target_kib(), + previous.page_cache_kib + ); + let response = source + .reader + .lock() + .unwrap() + .as_ref() + .unwrap() + .search("accepted", 3, false, &Arc::new(AtomicBool::new(false)), false) + .unwrap(); + assert_eq!(response.total_matches, 1); + } + + #[test] + fn idle_close_records_query_access_before_releasing_storage() { + let temporary = tempfile::tempdir().unwrap(); + let namespace = temporary.path().join("private"); + let source = LocalSource::new(temporary.path().join("root"), &namespace); + let index = DirectoryIndex::open(&source.index_path, SourceKind::Local, &json!({"root": "test"})).unwrap(); + *source.writer.lock().unwrap() = Some(index); + let accessed = epoch_milliseconds().unwrap(); + source.status.lock().unwrap().accessed_at_ms = Some(accessed); + let budget = Arc::new(DiskBudget { + namespace, + policy_gate: Mutex::new(()), + state: Mutex::new(DiskState { + policy: DirectoryPolicy::defaults().unwrap(), + reserved: 0, + next_cleanup_at: Instant::now(), + }), + space_probe: available_disk_bytes, + }); + source.close_idle(&budget).unwrap(); + assert_eq!(DiskBudget::inactive_access(&source.index_path).unwrap(), Some(accessed)); + assert!(source.writer.lock().unwrap().is_none()); + } + + #[test] + fn inactive_retention_count_and_leases_preserve_active_sources() { + let temporary = tempfile::tempdir().unwrap(); + let namespace = temporary.path().join("private"); + let mut paths = Vec::new(); + let mut active = None; + for (position, character) in ['a', 'b', 'c'].into_iter().enumerate() { + let path = namespace.join(format!("{}.sqlite3", character.to_string().repeat(64))); + let index = DirectoryIndex::open(&path, SourceKind::Local, &json!({"id": character})).unwrap(); + let connection = Connection::open(&path).unwrap(); + connection + .execute( + "UPDATE source_metadata SET last_access_at_ms = ? WHERE id = 1", + [i64::try_from(position + 1).unwrap() * 1000], + ) + .unwrap(); + drop(connection); + paths.push(path); + if character == 'c' { + active = Some(index); + } + } + let mut policy = DirectoryPolicy::defaults().unwrap(); + policy.max_retained_inactive_sources = 1; + let budget = DiskBudget { + namespace, + policy_gate: Mutex::new(()), + state: Mutex::new(DiskState { + policy: policy.clone(), + reserved: 0, + next_cleanup_at: Instant::now(), + }), + space_probe: available_disk_bytes, + }; + assert_eq!(budget.cleanup_inactive(&policy, 0, false, 4000).unwrap(), 1); + assert!(!paths[0].exists()); + assert!(paths[1].exists()); + assert!(paths[2].exists()); + assert_eq!(budget.cleanup_inactive(&policy, 0, false, 1_000_000_000).unwrap(), 1); + assert!(paths[2].exists()); + drop(active); + } + + #[test] + fn pressure_cleanup_accounts_quarantine_and_preserves_owned_catalogue() { + use super::super::directory_index::QUARANTINE_FILE_NAME; + + let temporary = tempfile::tempdir().unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("private")).unwrap(); + let path = manager.budget.namespace.join(format!("{}.sqlite3", "a".repeat(64))); + let mut index = DirectoryIndex::open(&path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + index.write_paths(["accepted".to_owned()], None).unwrap(); + index.finish_generation(1, 0).unwrap(); + let cancelled = Arc::new(AtomicBool::new(false)); + let before = index.search("accepted", 3, false, &cancelled, false).unwrap(); + for suffix in ["", "-journal"] { + let rejected = manager.budget.namespace.join(format!("{QUARANTINE_FILE_NAME}{suffix}")); + let bytes = manager.budget.managed_bytes().unwrap(); + std::fs::write(&rejected, b"rejected").unwrap(); + assert_eq!(manager.budget.managed_bytes().unwrap(), bytes + 8); + let policy = manager.budget.policy().unwrap(); + manager + .budget + .cleanup_inactive(&policy, 0, false, epoch_milliseconds().unwrap()) + .unwrap(); + assert!(rejected.exists()); + let lease = acquire_storage(&manager.budget.namespace.join(QUARANTINE_FILE_NAME)).unwrap(); + manager + .budget + .cleanup_inactive(&policy, 0, true, epoch_milliseconds().unwrap()) + .unwrap(); + assert!(rejected.exists()); + drop(lease); + manager + .budget + .cleanup_inactive(&policy, 0, true, epoch_milliseconds().unwrap()) + .unwrap(); + assert!(!rejected.exists()); + assert_eq!(index.search("accepted", 3, false, &cancelled, false).unwrap(), before); + } + } + + #[cfg(target_os = "linux")] + #[test] + fn background_scan_recovers_corruption_then_builds_current_directories() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("root"); + std::fs::create_dir(&root).unwrap(); + std::fs::create_dir(root.join("fresh")).unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("private")).unwrap(); + let source = LocalSource::new(root, &manager.budget.namespace); + let lease = acquire_storage(&source.index_path).unwrap(); + std::fs::write(&source.index_path, b"corrupt disposable catalogue").unwrap(); + drop(lease); + source.scan(&manager.budget).unwrap(); + let cancelled = Arc::new(AtomicBool::new(false)); + let writer = lock(&source.writer).unwrap(); + let response = writer.as_ref().unwrap().search("fresh", 3, false, &cancelled, false).unwrap(); + assert_eq!(response.total_matches, 1); + assert_eq!(response.coverage, "complete"); + assert!(source.recovery_attempted.load(Ordering::Acquire)); + assert_eq!(lock(&manager.budget.state).unwrap().reserved, 0); + } + + #[test] + fn orphan_lease_cleanup_is_bounded_and_preserves_owners_and_sidecars() { + let temporary = tempfile::tempdir().unwrap(); + let namespace = temporary.path().join("private"); + drop(super::super::directory_index::acquire_namespace_lease(&namespace).unwrap()); + let maximum = limit("lease_cleanup_batch").unwrap(); + for number in 0..maximum + 2 { + std::fs::write(namespace.join(format!("{number:064x}.sqlite3.lease")), []).unwrap(); + } + let held = namespace.join(format!("{}.sqlite3", "b".repeat(64))); + let lease = acquire_storage(&held).unwrap(); + let sidecar_stem = "c".repeat(64); + std::fs::write(namespace.join(format!("{sidecar_stem}.sqlite3.lease")), []).unwrap(); + std::fs::write(namespace.join(format!("{sidecar_stem}.sqlite3-wal")), b"held").unwrap(); + let budget = DiskBudget { + namespace: namespace.clone(), + policy_gate: Mutex::new(()), + state: Mutex::new(DiskState { + policy: DirectoryPolicy::defaults().unwrap(), + reserved: 0, + next_cleanup_at: Instant::now(), + }), + space_probe: available_disk_bytes, + }; + let namespace_lease = super::super::directory_index::acquire_namespace_lease(&namespace).unwrap(); + assert_eq!(budget.cleanup_orphan_leases().unwrap(), 0); + drop(namespace_lease); + assert_eq!(budget.cleanup_orphan_leases().unwrap(), maximum); + assert_eq!(budget.cleanup_orphan_leases().unwrap(), 2); + assert!(namespace.join(format!("{sidecar_stem}.sqlite3.lease")).exists()); + drop(lease); + assert_eq!(budget.cleanup_orphan_leases().unwrap(), 1); + assert!(namespace.join(format!("{sidecar_stem}.sqlite3.lease")).exists()); + } + + #[test] + fn quiet_source_does_not_request_checkpoint_admission() { + let temporary = tempfile::tempdir().unwrap(); + let namespace = temporary.path().join("private"); + let source = LocalSource::new(temporary.path().join("root"), &namespace); + let index = DirectoryIndex::open(&source.index_path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + *source.writer.lock().unwrap() = Some(index); + source.cleanup_pending.store(false, Ordering::Release); + source.observations.lock().unwrap().uncertain = false; + let budget = Arc::new(DiskBudget { + namespace, + policy_gate: Mutex::new(()), + state: Mutex::new(DiskState { + policy: DirectoryPolicy::defaults().unwrap(), + reserved: 0, + next_cleanup_at: Instant::now(), + }), + space_probe: |_| panic!("A small WAL must not request maintenance admission."), + }); + source.flush(&budget).unwrap(); + } + + #[test] + fn stopped_source_cannot_publish_query_results() { + let temporary = tempfile::tempdir().unwrap(); + let source = LocalSource::new(temporary.path().join("root"), temporary.path()); + assert!(source.ensure_available().is_ok()); + source.stopped.store(true, Ordering::Release); + assert!(source.ensure_available().is_err()); + } + + #[test] + fn query_ownership_defers_retired_cleanup_until_actual_exit() { + let temporary = tempfile::tempdir().unwrap(); + let namespace = temporary.path().join("private"); + let source = LocalSource::new(temporary.path().join("root"), &namespace); + let mut index = DirectoryIndex::open(&source.index_path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + index + .write_paths((0..ROW_BATCH_SIZE * 2).map(|number| format!("old/{number}")), None) + .unwrap(); + index.finish_generation(1, 0).unwrap(); + let candidate = index.begin_verification().unwrap(); + index.write_paths(["selected".to_owned()], Some(candidate)).unwrap(); + index.finish_generation(candidate, 0).unwrap(); + let reader = index.reader(); + *source.writer.lock().unwrap() = Some(index); + let mut budget = Arc::new(DiskBudget { + namespace, + policy_gate: Mutex::new(()), + state: Mutex::new(DiskState { + policy: DirectoryPolicy::defaults().unwrap(), + reserved: 0, + next_cleanup_at: Instant::now(), + }), + space_probe: available_disk_bytes, + }); + for number in 0..ROW_BATCH_SIZE + 1 { + assert!(lock(&source.observations) + .unwrap() + .enqueue( + DirectoryMutation::Add { + path: format!("foreground/{number}") + }, + limit("max_pending_observations").unwrap(), + limit("max_observation_bytes").unwrap(), + ) + .unwrap()); + } + source.reclamation_pending.store(true, Ordering::Release); + source.flush(&budget).unwrap(); + assert_eq!(lock(&source.observations).unwrap().pending.len(), 1); + assert!(source.cleanup_pending.load(Ordering::Acquire)); + assert!(source.reclamation_pending.load(Ordering::Acquire)); + let semaphore = Arc::new(Semaphore::new(1)); + source.active_queries.fetch_add(1, Ordering::AcqRel); + let ownership = QueryOwnership { + source: Arc::clone(&source), + _permit: semaphore.try_acquire_owned().unwrap(), + }; + let pressure = IndexError::StoragePaused("Test disk pressure."); + source.fail(&pressure, &budget); + source.fail(&pressure, &budget); + source.flush(&budget).unwrap(); + assert!(source.cleanup_pending.load(Ordering::Acquire)); + assert!(source.reclamation_pending.load(Ordering::Acquire)); + assert_eq!(source.active_queries.load(Ordering::Acquire), 1); + assert!(lock(&source.observations).unwrap().pending.is_empty()); + let cancelled = Arc::new(AtomicBool::new(false)); + let before = reader.search("selected foreground", 3, false, &cancelled, false).unwrap(); + assert_eq!(before.directory_count, ROW_BATCH_SIZE as i64 + 2); + source.busy.store(true, Ordering::Release); + drop(ownership); + source.flush(&budget).unwrap(); + assert!(source.cleanup_pending.load(Ordering::Acquire)); + assert!(source.reclamation_pending.load(Ordering::Acquire)); + source.busy.store(false, Ordering::Release); + source.flush(&budget).unwrap(); + assert!(source.cleanup_pending.load(Ordering::Acquire)); + assert!(source.reclamation_pending.load(Ordering::Acquire)); + { + let mut writer = lock(&source.writer).unwrap(); + let writer = writer.as_mut().unwrap(); + assert_eq!(writer.reconciliation_generation().unwrap(), Some(candidate)); + while let Some(parent) = writer.pending_scan_paths(candidate, 1).unwrap().into_iter().next() { + writer.finish_reconciliation_parent(candidate, &parent).unwrap(); + } + } + Arc::get_mut(&mut budget).unwrap().space_probe = |_| Ok(0); + assert!(matches!(source.flush(&budget), Err(IndexError::StoragePaused(_)))); + assert!(source.cleanup_pending.load(Ordering::Acquire)); + assert!(source.reclamation_pending.load(Ordering::Acquire)); + assert_eq!(lock(&budget.state).unwrap().reserved, 0); + assert_eq!(reader.search("selected foreground", 3, false, &cancelled, false).unwrap(), before); + Arc::get_mut(&mut budget).unwrap().space_probe = available_disk_bytes; + source.flush(&budget).unwrap(); + assert!(!source.cleanup_pending.load(Ordering::Acquire)); + assert!(!source.reclamation_pending.load(Ordering::Acquire)); + assert_eq!(source.active_queries.load(Ordering::Acquire), 0); + assert_eq!(reader.search("selected foreground", 3, false, &cancelled, false).unwrap(), before); + } + + #[test] + fn physical_disk_admission_retains_outstanding_reservations() { + let temporary = tempfile::tempdir().unwrap(); + let budget = Arc::new(DiskBudget { + namespace: temporary.path().to_path_buf(), + policy_gate: Mutex::new(()), + state: Mutex::new(DiskState { + policy: DirectoryPolicy::defaults().unwrap(), + reserved: 0, + next_cleanup_at: Instant::now(), + }), + space_probe: |_| u64::try_from(limit("write_headroom_bytes")?).map_err(|_| IndexError::Invalid("Invalid test headroom.")), + }); + let held = budget.reserve().unwrap(); + assert!(budget.reserve().is_err()); + assert!(lock(&budget.state).unwrap().reserved > 0); + drop(held); + assert_eq!(lock(&budget.state).unwrap().reserved, 0); + assert!(budget.reserve().is_ok()); + assert!(available_disk_bytes(temporary.path()).is_ok()); + } + + #[test] + fn shared_policy_defaults_and_strict_validation() { + let policy = DirectoryPolicy::defaults().unwrap(); + assert_eq!(policy.page_cache_kib, super::super::directory_index::PAGE_CACHE_KIB); + assert_eq!(limit("row_batch_size").unwrap(), ROW_BATCH_SIZE); + assert_eq!( + limit("max_observation_bytes").unwrap(), + super::super::directory_index::SCAN_BATCH_BYTES + ); + assert_eq!( + limit("maintenance_page_batch").unwrap(), + super::super::directory_index::MAINTENANCE_PAGE_BATCH as usize + ); + assert_eq!( + limit("checkpoint_wal_bytes").unwrap(), + super::super::directory_index::CHECKPOINT_WAL_BYTES as usize + ); + assert_eq!(limit("query_workers").unwrap(), 2); + let mut invalid = policy.clone(); + invalid.disk_budget_bytes = 0; + assert!(invalid.validate().is_err()); + invalid = policy.clone(); + invalid.degraded_verification_seconds = 3600; + assert!(invalid.validate().is_err()); + let mut serialized = serde_json::to_value(&policy).unwrap(); + serialized["page_cache_kib"] = json!(true); + assert!(serde_json::from_value::(serialized).is_err()); + } + + #[test] + fn failed_verification_uses_bounded_backoff_instead_of_the_full_pass_interval() { + let temporary = tempfile::tempdir().unwrap(); + let source = LocalSource::new(temporary.path().join("root"), temporary.path()); + let mut status = lock(&source.status).unwrap(); + let mut now = status.verified; + let interval = Duration::from_secs(900); + for seconds in [30, 60, 120, 240, 300, 300] { + status.schedule_retry(now, FAILURE_RETRY_JITTER_PERCENT); + let delay = Duration::from_secs(seconds); + assert_eq!(status.retry_at.saturating_duration_since(now), delay); + assert!(!status.verification_due(now + delay - Duration::from_millis(1), interval, true)); + assert!(status.verification_due(now + delay, interval, true)); + now += delay; + } + status.complete_pass(now); + assert!(!status.verification_due(now, interval, true)); + status.schedule_retry(now, FAILURE_RETRY_JITTER_PERCENT); + assert_eq!(status.failure_delay, FAILURE_RETRY_MIN); + } + + #[test] + fn retry_jitter_stays_within_limits_and_spreads_sources() { + let temporary = tempfile::tempdir().unwrap(); + let source = LocalSource::new(temporary.path().join("root"), temporary.path()); + let mut status = lock(&source.status).unwrap(); + let now = status.verified; + let mut delays = std::collections::HashSet::new(); + for jitter in 0..=u8::MAX { + status.failure_delay = Duration::ZERO; + status.schedule_retry(now, jitter); + let delay = status.retry_at.saturating_duration_since(now); + assert!((FAILURE_RETRY_MIN..=FAILURE_RETRY_MAX).contains(&delay)); + delays.insert(delay); + status.failure_delay = FAILURE_RETRY_MAX; + status.schedule_retry(now, jitter); + assert!((FAILURE_RETRY_MIN..=FAILURE_RETRY_MAX).contains(&status.retry_at.saturating_duration_since(now))); + } + assert!(delays.len() > 1); + } + + #[test] + fn periodic_verification_jitter_preserves_deadlines_and_retry_priority() { + let temporary = tempfile::tempdir().unwrap(); + let source = LocalSource::new(temporary.path().join("root"), temporary.path()); + let policy = DirectoryPolicy::defaults().unwrap(); + let mut status = lock(&source.status).unwrap(); + assert_eq!(status.verification_jitter, Sha256::digest(source.session.as_bytes())[0]); + let now = status.verified; + for seconds in [policy.healthy_verification_seconds, policy.degraded_verification_seconds] { + let interval = Duration::from_secs(seconds as u64); + let mut delays = std::collections::HashSet::new(); + for jitter in 0..=u8::MAX { + status.verification_jitter = jitter; + let percent = 100 - jitter % (PERIODIC_VERIFICATION_JITTER_PERCENT + 1); + let delay = interval.mul_f64(f64::from(percent) / 100.0); + delays.insert(delay); + assert!(delay >= interval.mul_f64(0.9) && delay <= interval); + assert!(!status.verification_due(now + delay - Duration::from_millis(1), interval, true)); + assert!(status.verification_due(now + delay, interval, true)); + status.retry_at = now + delay + FAILURE_RETRY_MIN; + assert!(!status.verification_due(now + delay, interval, true)); + assert!(status.verification_due(status.retry_at, interval, true)); + status.retry_at = now; + } + assert!(delays.len() > 1); + } + status.verification_jitter = 0; + let interval = Duration::from_secs(policy.healthy_verification_seconds as u64); + status.schedule_retry(now, FAILURE_RETRY_JITTER_PERCENT); + assert!(status.verification_due(status.retry_at, interval, true)); + status.complete_pass(now + FAILURE_RETRY_MIN); + assert!(!status.verification_due(status.verified, interval, true)); + assert!(status.verification_due(status.verified, interval, false)); + assert!(status.verification_due(status.verified + interval, interval, true)); + } + + #[test] + fn completed_partial_pass_waits_for_its_degraded_deadline() { + let temporary = tempfile::tempdir().unwrap(); + let source = LocalSource::new(temporary.path().join("root"), temporary.path()); + let mut status = lock(&source.status).unwrap(); + status.verification_jitter = 0; + let now = status.verified; + let interval = Duration::from_secs(300); + status.complete_pass(now); + assert!(!status.verification_due(now, interval, true)); + assert!(!status.verification_due(now + interval - Duration::from_millis(1), interval, true)); + assert!(status.verification_due(now + interval, interval, true)); + assert!(status.verification_due(now, interval, false)); + } + + #[test] + #[cfg(target_os = "linux")] + fn parent_pruning_removes_only_confirmed_missing_children_and_rejects_stale_reads() { + let temporary = tempfile::tempdir().unwrap(); + let namespace = temporary.path().join("private"); + let root_path = temporary.path().join("root"); + std::fs::create_dir_all(root_path.join("parent/keep/child")).unwrap(); + std::fs::create_dir(root_path.join("parent/gone-other")).unwrap(); + std::fs::write(root_path.join("parent/file"), b"fixture").unwrap(); + let root = NativeRoot::open(&root_path).unwrap(); + let identity = root.parent_identity("parent").unwrap(); + let source = LocalSource::new(root_path, &namespace); + let mut index = DirectoryIndex::open(&source.index_path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + index + .write_paths( + [ + "parent/gone", + "parent/gone/child", + "parent/file", + "parent/file/child", + "parent/keep", + "parent/keep/child", + "parent/gone-other", + ] + .map(str::to_owned), + None, + ) + .unwrap(); + index.finish_generation(1, 0).unwrap(); + let reader = index.reader(); + let cancelled = Arc::new(AtomicBool::new(false)); + let before = reader.search("keep", 3, false, &cancelled, false).unwrap(); + *lock(&source.writer).unwrap() = Some(index); + let budget = Arc::new(DiskBudget { + namespace, + policy_gate: Mutex::new(()), + state: Mutex::new(DiskState { + policy: DirectoryPolicy::defaults().unwrap(), + reserved: 0, + next_cleanup_at: Instant::now(), + }), + space_probe: available_disk_bytes, + }); + let read = source.begin_read().unwrap(); + let scan = ScanGeneration { id: 1, read: read.token }; + source.prune_missing_children(&root, "parent", identity, scan, &budget).unwrap(); + let after = reader.search("gone file keep", 3, false, &cancelled, false).unwrap(); + assert_eq!(after.directory_count, 3); + assert_eq!(after.last_verified_at_ms, before.last_verified_at_ms); + assert_eq!(reader.search("file", 3, false, &cancelled, false).unwrap().total_matches, 0); + assert_eq!(reader.search("gone-other", 3, false, &cancelled, false).unwrap().total_matches, 1); + source.invalidate().unwrap(); + assert!(matches!( + source.prune_missing_children(&root, "parent", identity, scan, &budget), + Err(IndexError::Interrupted) + )); + assert_eq!(reader.search("gone file keep", 3, false, &cancelled, false).unwrap(), after); + let current = source.begin_read().unwrap(); + std::fs::rename(source.root_path.join("parent"), source.root_path.join("replaced-parent")).unwrap(); + std::fs::create_dir(source.root_path.join("parent")).unwrap(); + assert!(source + .prune_missing_children( + &root, + "parent", + identity, + ScanGeneration { + id: 1, + read: current.token + }, + &budget + ) + .is_err()); + assert_eq!(reader.search("gone file keep", 3, false, &cancelled, false).unwrap(), after); + } + + #[test] + fn root_parent_barriers_reject_all_older_directory_reads() { + let mut observations = Observations::default(); + let old = observations.begin_read().unwrap(); + observations.enqueue(parent_observation("renamed"), 4, 64).unwrap(); + assert!(!observations.is_current(old, "unrelated/child")); + assert!(!observations.is_current(old, "")); + let current = observations.begin_read().unwrap(); + assert!(observations.is_current(current, "unrelated")); + } + + #[test] + fn repeated_watcher_loss_preserves_rows_and_recovers_through_verification() { + let temporary = tempfile::tempdir().unwrap(); + let namespace = temporary.path().join("private"); + let root_path = temporary.path().join("root"); + std::fs::create_dir_all(root_path.join("accepted/child")).unwrap(); + let source = LocalSource::new(root_path.clone(), &namespace); + let mut index = DirectoryIndex::open(&source.index_path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + index.write_paths(["accepted".into(), "accepted/child".into()], None).unwrap(); + index.finish_generation(1, 0).unwrap(); + let reader = index.reader(); + *lock(&source.writer).unwrap() = Some(index); + *lock(&source.root).unwrap() = Some(Arc::new(NativeRoot::open(&root_path).unwrap())); + let budget = Arc::new(DiskBudget { + namespace, + policy_gate: Mutex::new(()), + state: Mutex::new(DiskState { + policy: DirectoryPolicy::defaults().unwrap(), + reserved: 0, + next_cleanup_at: Instant::now(), + }), + space_probe: available_disk_bytes, + }); + let cancelled = Arc::new(AtomicBool::new(false)); + let before = reader.search("accepted", 3, false, &cancelled, false).unwrap(); + let mut uncertain = None; + for _ in 0..3 { + let read = source.begin_read().unwrap(); + source.observe(Err(notify::Error::generic("Injected watcher loss."))).unwrap(); + assert!(!lock(&source.observations).unwrap().is_current(read.token, "accepted")); + source.flush(&budget).unwrap(); + let response = reader.search("accepted", 3, false, &cancelled, false).unwrap(); + assert_eq!(response.directory_count, before.directory_count); + assert_eq!(response.total_matches, before.total_matches); + assert_eq!(response.last_verified_at_ms, before.last_verified_at_ms); + assert_eq!(response.coverage, "uncertain"); + assert_eq!(response.issue_count, 1); + if let Some(previous) = &uncertain { + assert_eq!(&response, previous); + } + uncertain = Some(response); + assert!(lock(&source.observations).unwrap().verification_required); + } + lock(&source.status).unwrap().watcher_retry_at = Instant::now() + FAILURE_RETRY_MAX; + let root = lock(&source.root).unwrap().as_ref().unwrap().clone(); + let mut candidate = None; + for event in [ + Err(notify::Error::generic("Injected watcher loss during verification.")), + Ok(Event::new(EventKind::Modify(ModifyKind::Any)).add_path(root_path.join("accepted"))), + ] { + source.flush(&budget).unwrap(); + let baseline = source.begin_read().unwrap(); + let generation = { + let mut writer = lock(&source.writer).unwrap(); + let index = writer.as_mut().unwrap(); + let (generation, selected) = index.prepare_scan().unwrap(); + assert!(!selected); + index.seed_scan(generation).unwrap(); + generation + }; + if let Some(previous) = candidate { + assert_eq!(generation, previous); + } + candidate = Some(generation); + let mut interruption = Some(event); + let outcome = source.scan_to_acceptance_with(&root, baseline.token, generation, &budget, |generation, parent| { + source.scan_parent(&root, generation, parent, &budget)?; + source.observe(interruption.take().unwrap()) + }); + assert!(matches!(outcome, Err(IndexError::Interrupted))); + assert!(interruption.is_none()); + assert!(!lock(&source.observations).unwrap().is_current(baseline.token, "")); + drop(baseline); + source.flush(&budget).unwrap(); + assert_eq!( + reader.search("accepted", 3, false, &cancelled, false).unwrap(), + *uncertain.as_ref().unwrap() + ); + let writer = lock(&source.writer).unwrap(); + let index = writer.as_ref().unwrap(); + assert!(index.has_resumable_scan().unwrap()); + assert!(!index.pending_scan_paths(generation, 1).unwrap().is_empty()); + } + source.scan(&budget).unwrap(); + assert!(lock(&source.watcher).unwrap().is_none()); + assert!(source.watcher_failed.load(Ordering::Acquire)); + assert_eq!( + lock(&source.writer).unwrap().as_ref().unwrap().accepted_generation().unwrap(), + candidate + ); + let recovered = reader.search("accepted", 3, false, &cancelled, false).unwrap(); + assert_eq!(recovered.coverage, "complete"); + assert_eq!(recovered.issue_count, 0); + assert!(!lock(&source.observations).unwrap().verification_required); + source.observe(Err(notify::Error::generic("Injected new watcher loss."))).unwrap(); + source.flush(&budget).unwrap(); + let lost_again = reader.search("accepted", 3, false, &cancelled, false).unwrap(); + assert_eq!(lost_again.coverage, "uncertain"); + assert_eq!(lost_again.issue_count, 1); + assert!(lost_again.accepted_revision > recovered.accepted_revision); + assert_eq!(lost_again.last_verified_at_ms, recovered.last_verified_at_ms); + } + + #[cfg(target_os = "linux")] + #[test] + fn unpaired_rename_hints_verify_directories_without_indexing_files() { + for modes in [ + vec![RenameMode::From], + vec![RenameMode::To], + vec![RenameMode::To, RenameMode::From], + vec![RenameMode::Any], + ] { + let temporary = tempfile::tempdir().unwrap(); + let namespace = temporary.path().join("private"); + let root_path = temporary.path().join("root"); + std::fs::create_dir_all(root_path.join("old/child")).unwrap(); + let source = LocalSource::new(root_path.clone(), &namespace); + let mut index = DirectoryIndex::open(&source.index_path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + index.write_paths(["old".into(), "old/child".into()], None).unwrap(); + index.finish_generation(1, 0).unwrap(); + let reader = index.reader(); + *lock(&source.writer).unwrap() = Some(index); + *lock(&source.root).unwrap() = Some(Arc::new(NativeRoot::open(&root_path).unwrap())); + std::fs::rename(root_path.join("old"), root_path.join("new")).unwrap(); + std::fs::write(root_path.join("renamed-file.txt"), b"fixture").unwrap(); + for mode in modes { + let path = if mode == RenameMode::From { "old" } else { "new" }; + source + .observe(Ok( + Event::new(EventKind::Modify(ModifyKind::Name(mode))).add_path(root_path.join(path)) + )) + .unwrap(); + } + source + .observe(Ok( + Event::new(EventKind::Modify(ModifyKind::Name(RenameMode::To))).add_path(root_path.join("renamed-file.txt")) + )) + .unwrap(); + let budget = Arc::new(DiskBudget { + namespace, + policy_gate: Mutex::new(()), + state: Mutex::new(DiskState { + policy: DirectoryPolicy::defaults().unwrap(), + reserved: 0, + next_cleanup_at: Instant::now(), + }), + space_probe: available_disk_bytes, + }); + source.flush(&budget).unwrap(); + let cancelled = Arc::new(AtomicBool::new(false)); + assert_eq!(reader.search("renamed-file", 3, false, &cancelled, false).unwrap().total_matches, 0); + assert!(lock(&source.observations).unwrap().verification_required); + source.scan(&budget).unwrap(); + assert_eq!( + reader + .search("old renamed-file", 3, false, &cancelled, false) + .unwrap() + .total_matches, + 0 + ); + let response = reader.search("new", 3, false, &cancelled, false).unwrap(); + assert_eq!(response.total_matches, 2); + assert_eq!(response.directory_count, 2); + assert!(!lock(&source.observations).unwrap().verification_required); + } + } + + #[test] + fn observation_overflow_invalidates_before_dropping_the_new_hint() { + let temporary = tempfile::tempdir().unwrap(); + let source = LocalSource::new(temporary.path().join("root"), temporary.path()); + for number in 0..limit("max_pending_observations").unwrap() + 1 { + source + .observe(Ok( + Event::new(EventKind::Create(CreateKind::Folder)).add_path(source.root_path.join(format!("directory-{number}"))) + )) + .unwrap(); + } + let observations = source.observations.lock().unwrap(); + assert_eq!(observations.pending.len(), limit("max_pending_observations").unwrap()); + assert!(observations.verification_required); + assert!(observations.uncertain); + assert_eq!(observations.epoch, 1); + } + + #[test] + fn single_event_byte_overflow_stops_before_copying_remaining_hints() { + let temporary = tempfile::tempdir().unwrap(); + let source = LocalSource::new(temporary.path().join("root"), temporary.path()); + let read = source.begin_read().unwrap(); + let bytes = limit("max_observation_bytes").unwrap() / 2 + 1; + let event = Event::new(EventKind::Create(CreateKind::Folder)) + .add_path(source.root_path.join("a".repeat(bytes))) + .add_path(source.root_path.join("b".repeat(bytes))) + .add_path(source.root_path.join("../not-parsed-after-overflow")); + source.observe(Ok(event)).unwrap(); + let observations = lock(&source.observations).unwrap(); + assert_eq!(observations.pending.len(), 1); + assert_eq!(observations.bytes, bytes); + assert_eq!(observations.epoch, 1); + assert!(observations.verification_required); + assert!(!observations.is_current(read.token, "")); + } + + #[test] + fn prefix_barriers_coalesce_and_keep_the_newest_sequence() { + let mut observations = Observations::default(); + let old = observations.begin_read().unwrap(); + for path in ["alpha/first", "unrelated", "alpha/second", "alpha", "alpha/next"] { + assert!(observations.enqueue(DirectoryMutation::Add { path: path.into() }, 3, 64).unwrap()); + observations.pending.clear(); + observations.bytes = 0; + observations.retire_barriers(); + } + assert_eq!(observations.epoch, 0); + assert_eq!(observations.barriers.len(), 2); + assert_eq!(observations.barrier_bytes, "alpha".len() + "unrelated".len()); + assert!(observations.barriers.contains(&(observations.sequence, "alpha".into()))); + assert!(!observations.is_current(old, "alpha/first")); + assert!(!observations.is_current(old, "")); + assert!(observations.is_current(old, "other")); + assert!(observations.is_current(old, "alphabet")); + } + + #[test] + fn barrier_retirement_waits_for_distinct_producers_and_queued_work() { + let mut observations = Observations::default(); + let first = observations.begin_read().unwrap(); + let second = observations.begin_read().unwrap(); + assert_ne!(first, second); + observations + .enqueue(DirectoryMutation::Remove { path: "alpha".into() }, 2, 64) + .unwrap(); + observations.end_read(first); + assert_eq!(observations.barriers.len(), 1); + observations.end_read(second); + assert_eq!(observations.barriers.len(), 1); + observations.pending.clear(); + observations.bytes = 0; + observations.retire_barriers(); + assert!(observations.barriers.is_empty()); + assert_eq!(observations.barrier_bytes, 0); + assert!(!observations.is_current(second, "alpha")); + } + + #[test] + fn barrier_overflow_invalidates_blocked_producers_before_retirement() { + for (maximum_pending, maximum_bytes) in [(1, 64), (4, 5)] { + let mut observations = Observations::default(); + let old = observations.begin_read().unwrap(); + observations + .enqueue(DirectoryMutation::Add { path: "alpha".into() }, maximum_pending, maximum_bytes) + .unwrap(); + observations.pending.clear(); + observations.bytes = 0; + assert!(observations + .enqueue(DirectoryMutation::Remove { path: "beta".into() }, maximum_pending, maximum_bytes) + .unwrap()); + assert_eq!(observations.epoch, 1); + assert!(observations.barriers.is_empty()); + assert_eq!(observations.barrier_bytes, 0); + assert_eq!(observations.pending.len(), 1); + assert_eq!(observations.producers.len(), 1); + assert!(observations.verification_required); + assert!(!observations.is_current(old, "alpha")); + assert!(!observations.is_current(old, "")); + observations.end_read(old); + assert!(observations.producers.is_empty()); + } + } + + #[test] + fn observation_read_releases_on_error_without_early_permit_release() { + let temporary = tempfile::tempdir().unwrap(); + let source = LocalSource::new(temporary.path().join("root"), temporary.path()); + source.busy.store(true, Ordering::Release); + let semaphore = Arc::new(Semaphore::new(1)); + let ownership = ScanOwnership { + source: Arc::clone(&source), + _permit: Arc::clone(&semaphore).try_acquire_owned().unwrap(), + }; + let read = source.begin_read().unwrap(); + source.invalidate().unwrap(); + assert_eq!(lock(&source.observations).unwrap().producers.len(), 1); + assert_eq!(semaphore.available_permits(), 0); + drop(read); + assert!(lock(&source.observations).unwrap().producers.is_empty()); + assert_eq!(semaphore.available_permits(), 0); + drop(ownership); + assert_eq!(semaphore.available_permits(), 1); + } + + #[test] + fn blocked_observation_read_keeps_ownership_through_overflow() { + for (maximum_pending, maximum_bytes, drain_first) in [(1, 64, false), (4, 14, false), (1, 64, true), (4, 14, true)] { + let temporary = tempfile::tempdir().unwrap(); + let root_path = temporary.path().join("root"); + std::fs::create_dir(&root_path).unwrap(); + let namespace = temporary.path().join("private"); + let source = LocalSource::new(root_path, &namespace); + let mut index = DirectoryIndex::open(&source.index_path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + index.write_paths(["accepted".into()], None).unwrap(); + assert!(index.finish_generation(1, 0).unwrap()); + let candidate = index.begin_verification().unwrap(); + index.write_paths(["candidate".into()], Some(candidate)).unwrap(); + let reader = index.reader(); + let cancelled = Arc::new(AtomicBool::new(false)); + let before = reader.search("accepted candidate stale", 3, false, &cancelled, false).unwrap(); + *lock(&source.writer).unwrap() = Some(index); + let budget = Arc::new(DiskBudget { + namespace, + policy_gate: Mutex::new(()), + state: Mutex::new(DiskState { + policy: DirectoryPolicy::defaults().unwrap(), + reserved: 0, + next_cleanup_at: Instant::now(), + }), + space_probe: available_disk_bytes, + }); + source.busy.store(true, Ordering::Release); + let semaphore = Arc::new(Semaphore::new(1)); + let ownership = ScanOwnership { + source: Arc::clone(&source), + _permit: Arc::clone(&semaphore).try_acquire_owned().unwrap(), + }; + let read = source.begin_read().unwrap(); + let token = read.token; + let (release, blocked) = std::sync::mpsc::channel::<()>(); + let worker = std::thread::spawn(move || { + let _ownership = ownership; + let _read = read; + blocked.recv().unwrap(); + }); + assert!(lock(&source.observations) + .unwrap() + .enqueue(DirectoryMutation::Add { path: "accepted".into() }, maximum_pending, maximum_bytes) + .unwrap()); + if drain_first { + source.flush(&budget).unwrap(); + } + { + let mut observations = lock(&source.observations).unwrap(); + assert_eq!( + observations + .enqueue( + DirectoryMutation::ReadParent { path: "deleted".into() }, + maximum_pending, + maximum_bytes + ) + .unwrap(), + drain_first + ); + assert_eq!(observations.epoch, 1); + assert!(observations.barriers.is_empty()); + assert_eq!(observations.barrier_bytes, 0); + assert!(!observations.is_current(token, "")); + assert_eq!(observations.producers.len(), 1); + assert_eq!(observations.pending.len(), 1); + assert_eq!(observations.bytes, if drain_first { "deleted".len() } else { "accepted".len() }); + assert!(!observations + .enqueue(DirectoryMutation::Add { path: "overflow".into() }, maximum_pending, maximum_bytes) + .unwrap()); + assert_eq!(observations.epoch, 2); + assert_eq!(observations.pending.len(), 1); + assert_eq!(observations.bytes, if drain_first { "deleted".len() } else { "accepted".len() }); + assert!(observations.verification_required); + } + source.flush(&budget).unwrap(); + #[cfg(target_os = "linux")] + { + let root = NativeRoot::open(&source.root_path).unwrap(); + let identity = root.parent_identity("").unwrap(); + assert!(matches!( + source.commit_children( + &root, + "", + identity, + ScanGeneration { + id: candidate, + read: token + }, + &[("stale".into(), false)], + &budget, + ), + Err(IndexError::Interrupted) + )); + assert!(matches!( + source.accept_generation(&root, token, candidate, &budget), + Err(IndexError::Interrupted) + )); + } + let response = reader.search("accepted candidate stale", 3, false, &cancelled, false).unwrap(); + assert_eq!(response.total_matches, 1); + assert_eq!(response.directory_count, 1); + assert_eq!(response.results, before.results); + assert_eq!(response.coverage, "uncertain"); + assert_eq!(response.issue_count, 1); + assert_eq!(response.last_verified_at_ms, before.last_verified_at_ms); + assert!(lock(&source.observations).unwrap().pending.is_empty()); + assert_eq!(lock(&source.observations).unwrap().bytes, 0); + assert_eq!(semaphore.available_permits(), 0); + assert!(source.busy.load(Ordering::Acquire)); + release.send(()).unwrap(); + worker.join().unwrap(); + assert!(lock(&source.observations).unwrap().producers.is_empty()); + assert_eq!(semaphore.available_permits(), 1); + assert!(!source.busy.load(Ordering::Acquire)); + assert!(lock(&source.observations).unwrap().verification_required); + } + } + + #[cfg(target_os = "linux")] + #[test] + fn accepted_parent_reconciliation_updates_tree_without_full_verification() { + for mode in ["paired", "from-to", "to-from", "unknown"] { + let temporary = tempfile::tempdir().unwrap(); + let root_path = temporary.path().join("root"); + for path in ["stable", "old/child", "gone/child"] { + std::fs::create_dir_all(root_path.join(path)).unwrap(); + } + std::fs::write(root_path.join("regular"), b"fixture").unwrap(); + let source = LocalSource::new(root_path.clone(), &temporary.path().join("private")); + let mut budget = Arc::new(DiskBudget { + namespace: temporary.path().join("private"), + policy_gate: Mutex::new(()), + state: Mutex::new(DiskState { + policy: DirectoryPolicy::defaults().unwrap(), + reserved: 0, + next_cleanup_at: Instant::now(), + }), + space_probe: available_disk_bytes, + }); + source.scan(&budget).unwrap(); + let accepted = lock(&source.writer).unwrap().as_ref().unwrap().accepted_generation().unwrap(); + lock(&source.watcher).unwrap().take(); + let reader = lock(&source.reader).unwrap().as_ref().unwrap().clone(); + let cancelled = Arc::new(AtomicBool::new(false)); + let before = reader.search("stable old gone", 3, false, &cancelled, false).unwrap(); + let verified = lock(&source.status).unwrap().verified; + std::fs::rename(root_path.join("old"), root_path.join("new")).unwrap(); + std::fs::create_dir_all(root_path.join("new/additional/deeper")).unwrap(); + std::fs::create_dir_all(root_path.join("created/deep")).unwrap(); + std::fs::remove_dir_all(root_path.join("gone")).unwrap(); + std::fs::rename(root_path.join("regular"), root_path.join("moved-file")).unwrap(); + let from = || Event::new(EventKind::Modify(ModifyKind::Name(RenameMode::From))).add_path(root_path.join("old")); + let to = || Event::new(EventKind::Modify(ModifyKind::Name(RenameMode::To))).add_path(root_path.join("new")); + match mode { + "paired" => source + .observe(Ok(Event::new(EventKind::Modify(ModifyKind::Name(RenameMode::Both))) + .add_path(root_path.join("old")) + .add_path(root_path.join("new")))) + .unwrap(), + "from-to" => { + source.observe(Ok(from())).unwrap(); + source.observe(Ok(to())).unwrap(); + } + "to-from" => { + source.observe(Ok(to())).unwrap(); + source.observe(Ok(from())).unwrap(); + } + _ => source + .observe(Ok( + Event::new(EventKind::Modify(ModifyKind::Name(RenameMode::Any))).add_path(root_path.join("new")) + )) + .unwrap(), + } + source + .observe(Ok( + Event::new(EventKind::Create(CreateKind::Folder)).add_path(root_path.join("created")) + )) + .unwrap(); + source + .observe(Ok( + Event::new(EventKind::Modify(ModifyKind::Name(RenameMode::Any))).add_path(root_path.join("moved-file")) + )) + .unwrap(); + source.flush(&budget).unwrap(); + { + let stale = source.begin_read().unwrap(); + let root = lock(&source.root).unwrap().clone().unwrap(); + let identity = root.parent_identity("").unwrap(); + source.invalidate().unwrap(); + assert!(matches!( + source.commit_children( + &root, + "", + identity, + ScanGeneration { id: 1, read: stale.token }, + &[("stale".into(), false)], + &budget, + ), + Err(IndexError::Interrupted) + )); + } + let paused = reader + .search("stable old new gone created moved-file", 3, false, &cancelled, false) + .unwrap(); + let scans = Arc::new(Semaphore::new(1)); + let held = Arc::clone(&scans).try_acquire_owned().unwrap(); + source.reconcile(&budget, &scans).unwrap(); + assert!(!source.busy.load(Ordering::Acquire)); + assert!(lock(&source.writer) + .unwrap() + .as_ref() + .unwrap() + .reconciliation_generation() + .unwrap() + .is_some()); + assert_eq!( + reader + .search("stable old new gone created moved-file", 3, false, &cancelled, false) + .unwrap(), + paused + ); + drop(held); + Arc::get_mut(&mut budget).unwrap().space_probe = |_| Ok(0); + let refusal = source.reconcile(&budget, &scans).unwrap_err(); + assert!(matches!(refusal, IndexError::StoragePaused(_))); + assert_eq!( + reader + .search("stable old new gone created moved-file", 3, false, &cancelled, false) + .unwrap(), + paused + ); + assert_eq!(scans.available_permits(), 1); + assert!(!source.busy.load(Ordering::Acquire)); + assert!(lock(&source.observations).unwrap().producers.is_empty()); + source.fail(&refusal, &budget); + Arc::get_mut(&mut budget).unwrap().space_probe = available_disk_bytes; + source.reconcile(&budget, &scans).unwrap(); + assert_eq!( + reader + .search("stable old new gone created moved-file", 3, false, &cancelled, false) + .unwrap(), + paused + ); + lock(&source.status).unwrap().retry_at = Instant::now(); + source.reconcile(&budget, &scans).unwrap(); + let after = reader.search("stable new created", 3, false, &cancelled, false).unwrap(); + assert_eq!(after.total_matches, 7); + assert_eq!(after.directory_count, 7); + assert_eq!( + reader + .search("old gone moved-file", 3, false, &cancelled, false) + .unwrap() + .total_matches, + 0 + ); + assert_eq!(after.last_verified_at_ms, before.last_verified_at_ms); + assert_eq!(lock(&source.status).unwrap().verified, verified); + assert_eq!(after.coverage, "uncertain"); + assert!(lock(&source.observations).unwrap().verification_required); + assert!(lock(&source.observations).unwrap().producers.is_empty()); + assert_eq!(scans.available_permits(), 1); + assert!(!source.busy.load(Ordering::Acquire)); + assert_eq!(lock(&budget.state).unwrap().reserved, 0); + let writer = lock(&source.writer).unwrap(); + let writer = writer.as_ref().unwrap(); + assert_eq!(writer.accepted_generation().unwrap(), accepted); + assert!(writer.reconciliation_generation().unwrap().is_none()); + } + } + + #[cfg(target_os = "linux")] + #[test] + #[ignore = "Explicit injected application resource measurement; not part of routine tests."] + fn injected_application_capacity_measurement() { + const DEFAULT_COUNT: usize = 100_000; + const QUERY_SAMPLES: usize = 30; + const SAMPLE_INTERVAL: Duration = Duration::from_millis(100); + const NANOSECONDS_PER_SECOND: f64 = 1_000_000_000.0; + + fn process_cpu_seconds() -> f64 { + let mut clock = libc::timespec { tv_sec: 0, tv_nsec: 0 }; + assert_eq!( + unsafe { libc::clock_gettime(libc::CLOCK_PROCESS_CPUTIME_ID, &mut clock) }, + 0, + "Cannot read the process CPU clock: {}", + std::io::Error::last_os_error() + ); + clock.tv_sec as f64 + clock.tv_nsec as f64 / NANOSECONDS_PER_SECOND + } + + fn process_memory() -> (u64, u64) { + let status = std::fs::read_to_string("/proc/self/status").unwrap(); + let value = |key| { + status + .lines() + .find(|line| line.starts_with(key)) + .unwrap() + .split_whitespace() + .nth(1) + .unwrap() + .parse::() + .unwrap() + }; + (value("VmRSS:"), value("VmHWM:")) + } + + let count = std::env::var("DIRECTORY_INDEX_MEASURE_COUNT") + .map(|value| value.parse::().unwrap()) + .unwrap_or(DEFAULT_COUNT); + assert!(count >= DEFAULT_COUNT); + let temporary = tempfile::tempdir().unwrap(); + let namespace = temporary.path().join("private"); + let root_path = temporary.path().join("root"); + std::fs::create_dir(&root_path).unwrap(); + let root = NativeRoot::open(&root_path).unwrap(); + let source = LocalSource::new(root_path, &namespace); + let identity = json!({"root": "injected-capacity-fixture"}); + let index = DirectoryIndex::open(&source.index_path, SourceKind::Local, &identity).unwrap(); + let reader = index.reader(); + *lock(&source.writer).unwrap() = Some(index); + source.busy.store(true, Ordering::Release); + let budget = Arc::new(DiskBudget { + namespace: namespace.clone(), + policy_gate: Mutex::new(()), + state: Mutex::new(DiskState { + policy: DirectoryPolicy::defaults().unwrap(), + reserved: 0, + next_cleanup_at: Instant::now(), + }), + space_probe: available_disk_bytes, + }); + let started = Instant::now(); + let started_cpu = process_cpu_seconds(); + let initial_memory = process_memory(); + let finished = Arc::new(AtomicBool::new(false)); + let sampled = Arc::new(Mutex::new( + json!({"rss_kib": 0, "fds": 0, "managed_bytes": 0, "main_bytes": 0, "wal_bytes": 0, "shm_bytes": 0}), + )); + let first_partial = Arc::new(Mutex::new(None)); + let sampler = { + let finished = Arc::clone(&finished); + let sampled = Arc::clone(&sampled); + let first_partial = Arc::clone(&first_partial); + let path = source.index_path.clone(); + let reader = reader.clone(); + let budget = Arc::clone(&budget); + std::thread::spawn(move || { + while !finished.load(Ordering::Acquire) { + let mut sample = lock(&sampled).unwrap(); + for (key, value) in [ + ("rss_kib", process_memory().0), + ("fds", std::fs::read_dir("/proc/self/fd").unwrap().count() as u64), + ("managed_bytes", budget.managed_bytes().unwrap()), + ] { + sample[key] = json!(sample[key].as_u64().unwrap().max(value)); + } + for (key, suffix) in [("main_bytes", ""), ("wal_bytes", "-wal"), ("shm_bytes", "-shm")] { + let value = std::fs::metadata(format!("{}{suffix}", path.display())) + .map(|metadata| metadata.len()) + .unwrap_or(0); + sample[key] = json!(sample[key].as_u64().unwrap().max(value)); + } + drop(sample); + if lock(&first_partial).unwrap().is_none() { + let result = reader.search("000099", 3, false, &Arc::new(AtomicBool::new(false)), false).unwrap(); + if !result.results.is_empty() { + *lock(&first_partial).unwrap() = Some(json!({ + "seconds": started.elapsed().as_secs_f64(), + "coverage": result.coverage, + "directory_count": result.directory_count + })); + } + } + std::thread::park_timeout(SAMPLE_INTERVAL); + } + }) + }; + let run_generation = || { + let baseline = source.begin_read().unwrap(); + let generation = { + let _reservation = budget.reserve().unwrap(); + let mut writer = lock(&source.writer).unwrap(); + let writer = writer.as_mut().unwrap(); + let (generation, _) = writer.prepare_scan().unwrap(); + writer.seed_scan(generation).unwrap(); + generation + }; + source + .scan_to_acceptance_with(&root, baseline.token, generation, &budget, |generation, parent| { + let read = source.begin_read()?; + if parent.is_empty() { + let mut batch = Vec::with_capacity(ROW_BATCH_SIZE); + for number in 0..count { + batch.push((format!("directory-{number:06}"), true)); + if batch.len() == ROW_BATCH_SIZE || number + 1 == count { + let observations = lock(&source.observations)?; + assert!(observations.is_current(read.token, parent)); + let _reservation = budget.reserve()?; + lock(&source.writer)? + .as_mut() + .unwrap() + .write_scan_children(generation, batch.drain(..))?; + } + } + } + let observations = lock(&source.observations)?; + assert!(observations.is_current(read.token, parent)); + let _reservation = budget.reserve()?; + lock(&source.writer)?.as_mut().unwrap().finish_scan_parent(generation, parent) + }) + .unwrap(); + }; + let begin = Instant::now(); + let begin_cpu = process_cpu_seconds(); + run_generation(); + let build_seconds = begin.elapsed().as_secs_f64(); + let build_cpu_seconds = process_cpu_seconds() - begin_cpu; + let cancelled = Arc::new(AtomicBool::new(false)); + let mut selective_ms = Vec::with_capacity(QUERY_SAMPLES); + for _ in 0..QUERY_SAMPLES { + let begin = Instant::now(); + let result = reader.search("090000", 3, false, &cancelled, false).unwrap(); + selective_ms.push(begin.elapsed().as_secs_f64() * 1000.0); + assert_eq!(result.directory_count, count as i64); + assert_eq!(result.total_matches, 1); + } + selective_ms.sort_by(f64::total_cmp); + let mut query_ms = serde_json::Map::new(); + for (label, query) in [("common", "directory"), ("mixed_short", "ry 090000")] { + let begin = Instant::now(); + let result = reader.search(query, 3, false, &cancelled, false).unwrap(); + query_ms.insert(label.into(), json!(begin.elapsed().as_secs_f64() * 1000.0)); + assert_eq!(result.total_matches, count as i64); + assert_eq!(result.results.len(), 3); + } + let verification_started = Instant::now(); + let verification_started_cpu = process_cpu_seconds(); + let verification_query_ms = std::thread::scope(|scope| { + let verification = scope.spawn(run_generation); + let begin = Instant::now(); + let result = reader.search("090000", 3, false, &cancelled, false).unwrap(); + assert_eq!(result.directory_count, count as i64); + assert_eq!(result.total_matches, 1); + let elapsed = begin.elapsed().as_secs_f64() * 1000.0; + verification.join().unwrap(); + elapsed + }); + let verification_seconds = verification_started.elapsed().as_secs_f64(); + let verification_cpu_seconds = process_cpu_seconds() - verification_started_cpu; + let small_reader = { + let mut writer = lock(&source.writer).unwrap(); + let writer = writer.as_mut().unwrap(); + writer.configure_page_cache(64).unwrap(); + writer.reader() + }; + assert_eq!( + small_reader.search("ry 090000", 3, false, &cancelled, false).unwrap().total_matches, + count as i64 + ); + finished.store(true, Ordering::Release); + sampler.thread().unpark(); + sampler.join().unwrap(); + lock(&source.writer).unwrap().take(); + let reopen_started = Instant::now(); + let reopened = DirectoryIndex::open(&source.index_path, SourceKind::Local, &identity).unwrap(); + assert_eq!( + reopened.search("090000", 3, false, &cancelled, false).unwrap().directory_count, + count as i64 + ); + eprintln!( + "DIRECTORY_INDEX_APPLICATION_MEASUREMENT {}", + json!({ + "rows": count, "build_seconds": build_seconds, "verification_seconds": verification_seconds, + "build_cpu_seconds": build_cpu_seconds, "verification_cpu_seconds": verification_cpu_seconds, + "query_during_verification_ms": verification_query_ms, "storage_reopen_seconds": reopen_started.elapsed().as_secs_f64(), + "selective_query_ms": {"samples": QUERY_SAMPLES, "p50": selective_ms[QUERY_SAMPLES / 2], "p95": selective_ms[(QUERY_SAMPLES * 95).div_ceil(100) - 1], "max": selective_ms[QUERY_SAMPLES - 1]}, + "broad_query_ms": query_ms, "first_partial": *lock(&first_partial).unwrap(), "sampled_peaks": *lock(&sampled).unwrap(), + "initial_rss_kib": initial_memory.0, "process_peak_rss_kib": process_memory().1, + "cache_target_kib_per_connection": DirectoryPolicy::defaults().unwrap().page_cache_kib, "small_cache_kib": 64, + "sqlite": rusqlite::version(), "total_seconds": started.elapsed().as_secs_f64(), + "total_cpu_seconds": process_cpu_seconds() - started_cpu + }) + ); + } + + #[cfg(any(target_os = "linux", target_os = "windows"))] + #[tokio::test] + async fn saturated_query_workers_return_retriable_http_status_and_recover() { + use axum::{body::Body, http::Request, routing::get, Router}; + use tower::ServiceExt; + + const MAX_RESPONSE_BYTES: usize = 64 * 1024; + let temporary = tempfile::tempdir().unwrap(); + let root_path = temporary.path().join("root"); + std::fs::create_dir(&root_path).unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("private")).unwrap(); + let source = LocalSource::new(root_path.clone(), &manager.budget.namespace); + let mut index = DirectoryIndex::open(&source.index_path, SourceKind::Local, &json!({"root": "query-capacity-fixture"})).unwrap(); + index.write_paths(["reports".into()], None).unwrap(); + assert!(index.finish_generation(1, 0).unwrap()); + *lock(&source.root).unwrap() = Some(Arc::new(NativeRoot::open(&root_path).unwrap())); + *lock(&source.reader).unwrap() = Some(index.reader()); + *lock(&source.writer).unwrap() = Some(index); + lock(&source.status).unwrap().state = "ready"; + source.busy.store(true, Ordering::Release); + let drive = format!("query-capacity-{}", uuid::Uuid::new_v4()); + super::super::drives::register_test_drive_path(drive.clone(), root_path); + lock(&manager.sources).unwrap().insert(drive.clone(), Arc::clone(&source)); + let query_capacity = manager.queries.available_permits(); + let mut permits = Some( + Arc::clone(&manager.queries) + .try_acquire_many_owned(u32::try_from(query_capacity).unwrap()) + .unwrap(), + ); + let router = Router::new() + .route( + "/api/browse/{drive}/directories", + get(super::super::handlers::browse_search_directories), + ) + .with_state(super::super::handlers::DirectoryMutationState(Arc::clone(&manager))); + for saturated in [true, false] { + let response = router + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/browse/{drive}/directories?q=reports")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), MAX_RESPONSE_BYTES).await.unwrap(); + let result: serde_json::Value = serde_json::from_slice(&body).unwrap(); + if saturated { + assert_eq!(status, axum::http::StatusCode::TOO_MANY_REQUESTS); + assert_eq!(result["detail"], IndexError::QueryBusy.to_string()); + } else { + assert_eq!(status, axum::http::StatusCode::OK); + assert_eq!(result["results"], json!(["reports"])); + } + if saturated { + drop(permits.take()); + } + } + assert_eq!(manager.queries.available_permits(), query_capacity); + assert_eq!(source.active_queries.load(Ordering::Acquire), 0); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + #[ignore = "Explicit seeded HTTP latency measurement; not part of routine tests."] + async fn seeded_directory_search_http_latency_measurement() { + use axum::{body::Body, http::Request, routing::get, Router}; + use tower::ServiceExt; + + const DEFAULT_COUNT: usize = 100_000; + const QUERY_SAMPLES: usize = 30; + const MAX_RESPONSE_BYTES: usize = 64 * 1024; + + let count = std::env::var("DIRECTORY_INDEX_HTTP_MEASURE_COUNT") + .map(|value| value.parse::().unwrap()) + .unwrap_or(DEFAULT_COUNT); + assert!(count > 0); + let temporary = tempfile::tempdir().unwrap(); + let root_path = temporary.path().join("root"); + std::fs::create_dir(&root_path).unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("private")).unwrap(); + let source = LocalSource::new(root_path.clone(), &manager.budget.namespace); + let mut index = DirectoryIndex::open(&source.index_path, SourceKind::Local, &json!({"root": "http-timing-fixture"})).unwrap(); + index + .write_paths((0..count).map(|number| format!("directory-{number:06}")), None) + .unwrap(); + assert!(index.finish_generation(1, 0).unwrap()); + *lock(&source.root).unwrap() = Some(Arc::new(NativeRoot::open(&root_path).unwrap())); + *lock(&source.reader).unwrap() = Some(index.reader()); + *lock(&source.writer).unwrap() = Some(index); + lock(&source.status).unwrap().state = "ready"; + source.busy.store(true, Ordering::Release); + let drive = format!("http-measurement-{}", uuid::Uuid::new_v4()); + super::super::drives::register_test_drive_path(drive.clone(), root_path); + lock(&manager.sources).unwrap().insert(drive.clone(), Arc::clone(&source)); + let router = Router::new() + .route( + "/api/browse/{drive}/directories", + get(super::super::handlers::browse_search_directories), + ) + .with_state(super::super::handlers::DirectoryMutationState(Arc::clone(&manager))); + let query = format!("{:06}", count - 1); + let mut samples = Vec::with_capacity(QUERY_SAMPLES); + for _ in 0..QUERY_SAMPLES { + let started = Instant::now(); + let response = router + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/browse/{drive}/directories?q={query}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), MAX_RESPONSE_BYTES).await.unwrap(); + samples.push(started.elapsed().as_secs_f64() * 1000.0); + assert_eq!(status, axum::http::StatusCode::OK, "{}", String::from_utf8_lossy(&body)); + let result: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(result["directory_count"].as_u64(), Some(count as u64)); + assert_eq!(result["total_matches"].as_u64(), Some(1)); + assert_eq!(result["results"], json!([format!("directory-{:06}", count - 1)])); + } + assert_eq!(source.active_queries.load(Ordering::Acquire), 0); + samples.sort_by(f64::total_cmp); + eprintln!( + "DIRECTORY_INDEX_HTTP_MEASUREMENT {}", + json!({ + "rows": count, "samples": QUERY_SAMPLES, + "p50_ms": (samples[QUERY_SAMPLES / 2 - 1] + samples[QUERY_SAMPLES / 2]) / 2.0, + "p95_ms": samples[(QUERY_SAMPLES * 95).div_ceil(100) - 1], "max_ms": samples[QUERY_SAMPLES - 1], + "method": "Axum routing, real native query worker and seeded SQLite; excludes pairing authentication, TCP and source build time", + "cache_target_kib_per_connection": DirectoryPolicy::defaults().unwrap().page_cache_kib, + "sqlite": rusqlite::version(), "debug_build": cfg!(debug_assertions) + }) + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn candidate_accepts_only_after_captured_parent_work_is_drained() { + let temporary = tempfile::tempdir().unwrap(); + let namespace = temporary.path().join("private"); + let root_path = temporary.path().join("root"); + std::fs::create_dir_all(root_path.join("old/child")).unwrap(); + std::fs::create_dir(root_path.join("stable")).unwrap(); + let root = NativeRoot::open(&root_path).unwrap(); + let source = LocalSource::new(root_path, &namespace); + let mut index = DirectoryIndex::open(&source.index_path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + index + .write_paths(["old".into(), "old/child".into(), "stable".into()], None) + .unwrap(); + assert!(index.finish_generation(1, 0).unwrap()); + let reader = index.reader(); + let generation = index.begin_verification().unwrap(); + index.seed_scan(generation).unwrap(); + *lock(&source.writer).unwrap() = Some(index); + source.busy.store(true, Ordering::Release); + let budget = Arc::new(DiskBudget { + namespace, + policy_gate: Mutex::new(()), + state: Mutex::new(DiskState { + policy: DirectoryPolicy::defaults().unwrap(), + reserved: 0, + next_cleanup_at: Instant::now(), + }), + space_probe: available_disk_bytes, + }); + let drain = |generation| loop { + let parents = lock(&source.writer) + .unwrap() + .as_ref() + .unwrap() + .pending_scan_paths(generation, 1) + .unwrap(); + let Some(parent) = parents.first() else { + break; + }; + source.scan_parent(&root, generation, parent, &budget).unwrap(); + }; + let baseline = source.begin_read().unwrap(); + drain(generation); + std::fs::rename(source.root_path.join("old"), source.root_path.join("new")).unwrap(); + source + .observe(Ok( + Event::new(EventKind::Modify(ModifyKind::Name(RenameMode::To))).add_path(source.root_path.join("new")) + )) + .unwrap(); + source.flush(&budget).unwrap(); + assert!(!lock(&source.observations).unwrap().is_current(baseline.token, "")); + assert!(matches!( + source.accept_generation(&root, baseline.token, generation, &budget), + Err(IndexError::Interrupted) + )); + drain(generation); + assert!(matches!( + source.accept_generation(&root, baseline.token, generation, &budget), + Err(IndexError::Interrupted) + )); + drain(1); + let outstanding = source.begin_read().unwrap(); + assert!(matches!( + source.accept_generation(&root, baseline.token, generation, &budget), + Err(IndexError::Interrupted) + )); + drop(outstanding); + source + .observe(Ok( + Event::new(EventKind::Modify(ModifyKind::Name(RenameMode::To))).add_path(source.root_path.join("new")) + )) + .unwrap(); + source.scan_to_acceptance(&root, baseline.token, generation, &budget).unwrap(); + let cancelled = Arc::new(AtomicBool::new(false)); + let result = reader.search("old new stable", 3, false, &cancelled, false).unwrap(); + assert_eq!(result.total_matches, 3); + assert_eq!(result.coverage, "complete"); + assert_eq!(result.issue_count, 0); + assert_eq!(reader.search("old", 3, false, &cancelled, false).unwrap().total_matches, 0); + assert_eq!( + lock(&source.writer).unwrap().as_ref().unwrap().accepted_generation().unwrap(), + Some(generation) + ); + assert!(!lock(&source.observations).unwrap().verification_required); + let captured = lock(&source.observations).unwrap().sequence; + source + .observe(Ok( + Event::new(EventKind::Modify(ModifyKind::Name(RenameMode::To))).add_path(source.root_path.join("stable/later")) + )) + .unwrap(); + let observations = lock(&source.observations).unwrap(); + assert!(observations.sequence > captured); + assert!(!observations.pending.is_empty()); + assert!(observations.verification_required); + drop(observations); + assert_eq!(reader.search("old new stable", 3, false, &cancelled, false).unwrap(), result); + drop(baseline); + assert!(lock(&source.observations).unwrap().producers.is_empty()); + assert_eq!(lock(&budget.state).unwrap().reserved, 0); + } + + #[cfg(target_os = "linux")] + #[test] + fn candidate_capture_refusal_preserves_selected_rows_and_freshness() { + for refusal in ["epoch", "stopped", "completed", "uncertain", "pending", "producer"] { + let temporary = tempfile::tempdir().unwrap(); + let namespace = temporary.path().join("private"); + let root_path = temporary.path().join("root"); + std::fs::create_dir_all(root_path.join("stable")).unwrap(); + let root = NativeRoot::open(&root_path).unwrap(); + let source = LocalSource::new(root_path, &namespace); + let mut index = DirectoryIndex::open(&source.index_path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + index.write_paths(["stable".into()], None).unwrap(); + assert!(index.finish_generation(1, 0).unwrap()); + let generation = index.begin_verification().unwrap(); + index.write_paths(["stable".into()], Some(generation)).unwrap(); + let reader = index.reader(); + *lock(&source.writer).unwrap() = Some(index); + let budget = Arc::new(DiskBudget { + namespace, + policy_gate: Mutex::new(()), + state: Mutex::new(DiskState { + policy: DirectoryPolicy::defaults().unwrap(), + reserved: 0, + next_cleanup_at: Instant::now(), + }), + space_probe: available_disk_bytes, + }); + let cancelled = Arc::new(AtomicBool::new(false)); + let before = reader.search("stable", 3, false, &cancelled, false).unwrap(); + let baseline = source.begin_read().unwrap(); + let token = baseline.token; + let mut held_baseline = Some(baseline); + let mut other = None; + match refusal { + "epoch" => source.invalidate().unwrap(), + "stopped" => source.stopped.store(true, Ordering::Release), + "completed" => drop(held_baseline.take()), + "uncertain" => lock(&source.observations).unwrap().uncertain = true, + "pending" => { + source + .observe(Ok( + Event::new(EventKind::Modify(ModifyKind::Name(RenameMode::To))).add_path(source.root_path.join("later")) + )) + .unwrap(); + } + "producer" => other = Some(source.begin_read().unwrap()), + _ => unreachable!(), + } + assert!( + matches!( + source.accept_generation(&root, token, generation, &budget), + Err(IndexError::Interrupted) + ), + "Unexpected acceptance: {refusal}" + ); + assert_eq!(reader.search("stable", 3, false, &cancelled, false).unwrap(), before); + assert_eq!( + lock(&source.writer).unwrap().as_ref().unwrap().accepted_generation().unwrap(), + Some(1) + ); + assert_eq!(lock(&budget.state).unwrap().reserved, 0); + drop(other); + drop(held_baseline); + assert!(lock(&source.observations).unwrap().producers.is_empty()); + } + } + + #[cfg(target_os = "linux")] + #[test] + fn late_scan_batches_and_candidate_selection_cannot_restore_deleted_rows() { + let temporary = tempfile::tempdir().unwrap(); + let namespace = temporary.path().join("private"); + let root_path = temporary.path().join("root"); + std::fs::create_dir(&root_path).unwrap(); + std::fs::create_dir(root_path.join("alpha")).unwrap(); + let root = NativeRoot::open(&root_path).unwrap(); + let parent_identity = root.parent_identity("alpha").unwrap(); + let source = LocalSource::new(root_path, &namespace); + let mut index = DirectoryIndex::open(&source.index_path, SourceKind::Local, &json!({"root": "fixture"})).unwrap(); + index + .write_paths(["alpha".into(), "alpha/child".into(), "unrelated".into()], None) + .unwrap(); + index.finish_generation(1, 0).unwrap(); + let generation = index.begin_verification().unwrap(); + index.write_paths(["alpha".into(), "candidate".into()], Some(generation)).unwrap(); + let reader = index.reader(); + *lock(&source.writer).unwrap() = Some(index); + source.busy.store(true, Ordering::Release); + let baseline = source.begin_read().unwrap(); + let read = source.begin_read().unwrap(); + let budget = Arc::new(DiskBudget { + namespace, + policy_gate: Mutex::new(()), + state: Mutex::new(DiskState { + policy: DirectoryPolicy::defaults().unwrap(), + reserved: 0, + next_cleanup_at: Instant::now(), + }), + space_probe: available_disk_bytes, + }); + source + .observe(Ok( + Event::new(EventKind::Remove(RemoveKind::Folder)).add_path(source.root_path.join("alpha")) + )) + .unwrap(); + source.flush(&budget).unwrap(); + assert!(matches!( + source.commit_children( + &root, + "alpha", + parent_identity, + ScanGeneration { + id: generation, + read: read.token + }, + &[("alpha/stale".into(), false)], + &budget, + ), + Err(IndexError::Interrupted) + )); + assert!(matches!( + source.accept_generation(&root, baseline.token, generation, &budget), + Err(IndexError::Interrupted) + )); + let cancelled = Arc::new(AtomicBool::new(false)); + assert_eq!(reader.search("alpha", 3, false, &cancelled, false).unwrap().total_matches, 0); + assert_eq!( + reader + .search("unrelated candidate", 3, false, &cancelled, false) + .unwrap() + .total_matches, + 1 + ); + assert!(lock(&source.observations).unwrap().verification_required); + } + + #[test] + fn scan_permit_is_owned_until_the_real_worker_drops() { + let temporary = tempfile::tempdir().unwrap(); + let source = LocalSource::new(temporary.path().join("root"), temporary.path()); + source.busy.store(true, Ordering::Release); + let semaphore = Arc::new(Semaphore::new(1)); + let ownership = ScanOwnership { + source: Arc::clone(&source), + _permit: Arc::clone(&semaphore).try_acquire_owned().unwrap(), + }; + source.invalidate().unwrap(); + assert_eq!(semaphore.available_permits(), 0); + assert!(source.busy.load(Ordering::Acquire)); + drop(ownership); + assert_eq!(semaphore.available_permits(), 1); + assert!(!source.busy.load(Ordering::Acquire)); + } + + #[test] + fn update_ownership_releases_on_worker_failure() { + let temporary = tempfile::tempdir().unwrap(); + let source = LocalSource::new(temporary.path().join("root"), temporary.path()); + source.flush_busy.store(true, Ordering::Release); + let semaphore = Arc::new(Semaphore::new(1)); + let ownership = UpdateOwnership { + source: Arc::clone(&source), + _permit: Arc::clone(&semaphore).try_acquire_owned().unwrap(), + }; + let outcome = std::thread::spawn(move || { + let _ownership = ownership; + panic!("Injected worker failure"); + }) + .join(); + assert!(outcome.is_err()); + assert!(!source.flush_busy.load(Ordering::Acquire)); + assert_eq!(semaphore.available_permits(), 1); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn accepted_parent_reconciliation_runs_in_update_worker() { + let temporary = tempfile::tempdir().unwrap(); + let root_path = temporary.path().join("root"); + std::fs::create_dir_all(root_path.join("stable")).unwrap(); + let namespace = temporary.path().join("private"); + let budget = Arc::new(DiskBudget { + namespace: namespace.clone(), + policy_gate: Mutex::new(()), + state: Mutex::new(DiskState { + policy: DirectoryPolicy::defaults().unwrap(), + reserved: 0, + next_cleanup_at: Instant::now(), + }), + space_probe: available_disk_bytes, + }); + let source = LocalSource::new(root_path.clone(), &namespace); + source.scan(&budget).unwrap(); + let accepted = lock(&source.writer).unwrap().as_ref().unwrap().accepted_generation().unwrap(); + lock(&source.watcher).unwrap().take(); + lock(&source.status).unwrap().watcher_retry_at = Instant::now() + FAILURE_RETRY_MAX; + let reader = lock(&source.reader).unwrap().as_ref().unwrap().clone(); + let cancelled = Arc::new(AtomicBool::new(false)); + let before = reader.search("stable incoming", 3, false, &cancelled, false).unwrap(); + let verified = lock(&source.status).unwrap().verified; + let manager = Arc::new(LocalDirectoryManager { + sources: Mutex::new(HashMap::from([("fixture".into(), Arc::clone(&source))])), + budget, + scans: Arc::new(Semaphore::new(limit("scan_workers").unwrap())), + queries: Arc::new(Semaphore::new(limit("query_workers").unwrap())), + updates: Arc::new(Semaphore::new(limit("query_workers").unwrap())), + }); + std::fs::create_dir_all(root_path.join("incoming/child")).unwrap(); + source + .observe(Ok( + Event::new(EventKind::Modify(ModifyKind::Name(RenameMode::To))).add_path(root_path.join("incoming")) + )) + .unwrap(); + manager.tick().unwrap(); + let _updates = tokio::time::timeout( + Duration::from_secs(10), + Arc::clone(&manager.updates).acquire_many_owned(u32::try_from(limit("query_workers").unwrap()).unwrap()), + ) + .await + .unwrap() + .unwrap(); + let after = reader.search("stable incoming", 3, false, &cancelled, false).unwrap(); + assert_eq!(after.directory_count, 3); + assert_eq!(after.total_matches, 3); + assert_eq!(after.last_verified_at_ms, before.last_verified_at_ms); + assert_eq!(lock(&source.status).unwrap().verified, verified); + assert_eq!( + lock(&source.writer).unwrap().as_ref().unwrap().accepted_generation().unwrap(), + accepted + ); + assert!(!source.busy.load(Ordering::Acquire)); + assert!(!source.flush_busy.load(Ordering::Acquire)); + assert!(lock(&source.observations).unwrap().producers.is_empty()); + assert_eq!(manager.scans.available_permits(), limit("scan_workers").unwrap()); + assert_eq!(lock(&manager.budget.state).unwrap().reserved, 0); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn cold_initialization_is_shared_and_warm_search_does_not_scan() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("root"); + std::fs::create_dir(&root).unwrap(); + std::fs::create_dir(root.join("alpha")).unwrap(); + std::fs::create_dir(root.join(".hidden")).unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("private")).unwrap(); + let started = Instant::now(); + let cold = manager.search("fixture".into(), root.clone(), "".into(), false).await.unwrap(); + assert_eq!(cold.total_matches, 0); + assert_eq!(cold.search_contract_version, Some(SEARCH_CONTRACT_VERSION)); + assert!(started.elapsed() < Duration::from_secs(1)); + let source = Arc::clone(manager.sources.lock().unwrap().get("fixture").unwrap()); + tokio::time::timeout(Duration::from_secs(10), async { + loop { + let finished = source.finished.notified(); + tokio::pin!(finished); + finished.as_mut().enable(); + if !source.busy.load(Ordering::Acquire) { + break; + } + finished.await; + } + }) + .await + .unwrap(); + let warm = manager.search("fixture".into(), root.clone(), "alpha".into(), false).await.unwrap(); + assert_eq!(warm.results, vec!["alpha"]); + assert_eq!(warm.directory_count, 2); + assert_eq!(warm.coverage.as_deref(), Some("complete")); + assert_eq!(warm.search_contract_version, Some(SEARCH_CONTRACT_VERSION)); + assert_eq!( + serde_json::to_value(&warm).unwrap()["search_contract_version"], + SEARCH_CONTRACT_VERSION + ); + let progress = source.status.lock().unwrap().progress; + manager.search("fixture".into(), root, "hidden alpha".into(), true).await.unwrap(); + assert_eq!(source.status.lock().unwrap().progress, progress); + assert_eq!(manager.sources.lock().unwrap().len(), 1); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn replaced_root_keeps_index_and_rebinds_the_same_source() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("root"); + std::fs::create_dir(&root).unwrap(); + std::fs::create_dir(root.join("old")).unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("private")).unwrap(); + let old = LocalSource::new(root.clone(), &manager.budget.namespace); + old.scan(&manager.budget).unwrap(); + manager.sources.lock().unwrap().insert("fixture".into(), Arc::clone(&old)); + std::fs::rename(&root, temporary.path().join("old-root")).unwrap(); + std::fs::create_dir(&root).unwrap(); + std::fs::create_dir(root.join("new")).unwrap(); + assert!(manager.search("fixture".into(), root.clone(), "old".into(), false).await.is_err()); + assert!(!old.stopped.load(Ordering::Acquire)); + let new = Arc::clone(manager.sources.lock().unwrap().get("fixture").unwrap()); + assert!(Arc::ptr_eq(&new, &old)); + assert_eq!(new.index_path, old.index_path); + tokio::time::timeout(Duration::from_secs(10), async { + loop { + let finished = new.finished.notified(); + tokio::pin!(finished); + finished.as_mut().enable(); + if !new.busy.load(Ordering::Acquire) { + break; + } + finished.await; + } + }) + .await + .unwrap(); + let response = manager.search("fixture".into(), root, "old new".into(), false).await.unwrap(); + assert_eq!(response.results, ["new"]); + } +} diff --git a/companion/src-tauri/src/server/directory_policy.rs b/companion/src-tauri/src/server/directory_policy.rs new file mode 100644 index 000000000..a22ddc19d --- /dev/null +++ b/companion/src-tauri/src/server/directory_policy.rs @@ -0,0 +1,1308 @@ +//! Local-owner consent and direct redemption of backend-issued directory policy grants. + +#[cfg(unix)] +use std::fs::File; +use std::fs::OpenOptions; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock, TryLockError, Weak}; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Manager}; +use tauri_plugin_dialog::{DialogExt, MessageDialogButtons}; + +use super::directory_manager::{DirectoryPolicy, LocalDirectoryManager, MAX_WIRE_INTEGER}; +use super::errors::ApiError; + +const POLICY_VERSION: u32 = 1; +const CHALLENGE_TIMEOUT: Duration = Duration::from_secs(120); +const APPROVAL_COOLDOWN: Duration = Duration::from_secs(5); +const REDEMPTION_TIMEOUT: Duration = Duration::from_secs(15); +const MAX_RESPONSE_BYTES: usize = 16_384; +const MAX_GRANT_BYTES: usize = 8192; +const MAX_ORIGIN_BYTES: usize = 2048; +const MAX_POLICY_FILE_BYTES: u64 = 65_536; +const POLICY_FILE: &str = "approved-policy.json"; +const POLICY_STAGE_FILE: &str = "approved-policy.pending"; +static PROTECTED_NATIVE_STORAGE: OnceLock> = OnceLock::new(); + +fn canonical_storage_path(path: &Path) -> Result { + let mut existing = path; + let mut suffix = Vec::new(); + loop { + match std::fs::symlink_metadata(existing) { + Ok(_) => break, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + suffix.push(existing.file_name().ok_or_else(|| failure("Mutation path is invalid."))?); + existing = existing.parent().ok_or_else(|| failure("Mutation path is invalid."))?; + } + Err(error) => return Err(error.into()), + } + } + let mut resolved = existing.canonicalize()?; + for component in suffix.iter().rev() { + resolved.push(component); + } + Ok(resolved) +} + +#[cfg(not(windows))] +fn storage_path_starts_with(path: &Path, parent: &Path) -> Result { + Ok(path.starts_with(parent)) +} + +#[cfg(windows)] +fn storage_path_starts_with(path: &Path, parent: &Path) -> Result { + use std::os::windows::ffi::OsStrExt; + use windows::Win32::Globalization::{CompareStringOrdinal, CSTR_EQUAL, CSTR_GREATER_THAN, CSTR_LESS_THAN}; + + let mut components = path.components(); + for parent_component in parent.components() { + let Some(component) = components.next() else { + return Ok(false); + }; + let candidate: Vec = component.as_os_str().encode_wide().collect(); + let expected: Vec = parent_component.as_os_str().encode_wide().collect(); + if i32::try_from(candidate.len()).is_err() || i32::try_from(expected.len()).is_err() { + return Err(failure( + "Native storage path is too long to compare safely. Select a shorter storage path.", + )); + } + let comparison = unsafe { CompareStringOrdinal(&candidate, &expected, true) }; + if comparison == CSTR_EQUAL { + continue; + } + if comparison == CSTR_LESS_THAN || comparison == CSTR_GREATER_THAN { + return Ok(false); + } + return Err(failure( + "Native storage path comparison failed. Retry after correcting the storage path.", + )); + } + Ok(true) +} + +fn ensure_outside_policy_storage(path: &Path, protected: &Path) -> Result<(), ApiError> { + let resolved = canonical_storage_path(path)?; + let protected = canonical_storage_path(protected)?; + if storage_path_starts_with(&resolved, &protected)? || storage_path_starts_with(&protected, &resolved)? { + return Err(ApiError::forbidden_code( + "Companion internal storage can change only through native controls. Select a different file or directory.", + "directory_policy_storage_protected", + )); + } + Ok(()) +} + +pub(super) fn ensure_mutation_allowed(path: &Path) -> Result<(), ApiError> { + if let Some(roots) = PROTECTED_NATIVE_STORAGE.get() { + for protected in roots { + ensure_outside_policy_storage(path, protected)?; + } + } + Ok(()) +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PolicyReceipt { + version: u32, + companion_id: uuid::Uuid, + challenge: String, + origin: String, + backend_identity: String, + revision: i64, + policy: DirectoryPolicy, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct PersistedPolicy { + version: u32, + companion_id: uuid::Uuid, + approved: Option, +} + +struct PendingChallenge { + value: String, + origin: String, + created: Instant, + approved: bool, + redeeming: bool, +} +struct PolicyState { + stored: PersistedPolicy, + pending: Option, + last_requested: Option, + prompt_active: bool, + status: &'static str, + message: Option, + blocked: bool, +} + +#[derive(Serialize)] +pub struct DirectoryPolicyStatus { + pub version: u32, + pub companion_id: uuid::Uuid, + pub status: &'static str, + pub owner_origin: Option, + pub backend_identity: Option, + pub effective_revision: Option, + pub policy: DirectoryPolicy, + pub challenge: Option, + pub approved: bool, + pub message: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PolicyGrantDelivery { + pub grant: String, +} + +pub struct DirectoryPolicyState { + directory: PathBuf, + _lease: std::fs::File, + state: Mutex, + manager: Weak, +} + +struct PolicyRedemptionOwnership { + policy: Arc, + challenge: String, +} + +impl Drop for PolicyRedemptionOwnership { + fn drop(&mut self) { + if let Ok(mut state) = self.policy.state.lock() { + if state + .pending + .as_ref() + .is_some_and(|pending| pending.value == self.challenge && pending.redeeming) + { + state.pending = None; + state.status = "failed"; + state.message = + Some("Policy redemption failed. Existing approved policy remains active. Request a new approval grant.".to_owned()); + } + } + } +} + +fn failure(message: &str) -> ApiError { + ApiError::BadRequest(message.to_owned()) +} + +fn validate_origin(origin: &str) -> Result { + if origin.len() > MAX_ORIGIN_BYTES { + return Err(failure("Policy origin is too long.")); + } + let parsed = url::Url::parse(origin).map_err(|_| failure("Policy origin is invalid."))?; + let loopback = match parsed.host() { + Some(url::Host::Domain(host)) => host == "localhost", + Some(url::Host::Ipv4(address)) => address.is_loopback(), + Some(url::Host::Ipv6(address)) => address.is_loopback(), + None => false, + }; + if parsed.scheme() != "https" && !(parsed.scheme() == "http" && loopback) { + return Err(failure("Policy redemption requires HTTPS, or HTTP on a loopback host.")); + } + if !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + || parsed.path() != "/" + || parsed.origin().ascii_serialization() != origin + { + return Err(failure("Policy origin must contain only the scheme, host and optional port.")); + } + Ok(parsed) +} + +fn validate_private_file(path: &Path) -> Result<(), ApiError> { + let metadata = std::fs::symlink_metadata(path) + .map_err(|_| failure("Approved policy storage is unavailable. Check private configuration storage."))?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(failure("Approved policy storage must be a private regular file without links.")); + } + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + if metadata.uid() != unsafe { libc::geteuid() } || metadata.permissions().mode() & 0o077 != 0 || metadata.nlink() != 1 { + return Err(failure( + "Approved policy storage must belong to the current user with mode 0600 and no hard links.", + )); + } + } + Ok(()) +} + +fn private_directory(directory: &Path) -> Result<(), ApiError> { + for ancestor in directory.ancestors() { + match std::fs::symlink_metadata(ancestor) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(failure("Approved policy storage must not use symbolic links.")) + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => { + return Err(failure( + "Approved policy directory cannot be checked. Correct private configuration storage.", + )) + } + } + } + let mut builder = std::fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder + .create(directory) + .map_err(|_| failure("Approved policy directory cannot be created. Correct private configuration storage."))?; + let metadata = std::fs::symlink_metadata(directory).map_err(|_| failure("Approved policy directory cannot be checked."))?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(failure("Approved policy storage is not a private directory.")); + } + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + if metadata.uid() != unsafe { libc::geteuid() } || metadata.permissions().mode() & 0o077 != 0 { + return Err(failure("Approved policy directory must belong to the current user with mode 0700.")); + } + } + Ok(()) +} + +fn save(directory: &Path, stored: &PersistedPolicy) -> Result<(), ApiError> { + private_directory(directory)?; + let destination = directory.join(POLICY_FILE); + if destination + .try_exists() + .map_err(|_| failure("Approved policy file cannot be checked."))? + { + validate_private_file(&destination)?; + } + let stage = directory.join(POLICY_STAGE_FILE); + if stage + .try_exists() + .map_err(|_| failure("Approved policy staging file cannot be checked."))? + { + validate_private_file(&stage)?; + std::fs::remove_file(&stage).map_err(|_| failure("Stale policy staging data cannot be removed."))?; + } + let bytes = serde_json::to_vec(stored).map_err(|_| failure("Approved policy cannot be serialized."))?; + if bytes.len() as u64 > MAX_POLICY_FILE_BYTES { + return Err(failure("Approved policy exceeds its storage limit.")); + } + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + let mut file = options + .open(&stage) + .map_err(|_| failure("Approved policy staging data cannot be created."))?; + file.write_all(&bytes) + .and_then(|_| file.sync_all()) + .map_err(|_| failure("Approved policy could not be written durably. Correct configuration storage and retry."))?; + drop(file); + std::fs::rename(&stage, &destination) + .map_err(|_| failure("Approved policy could not be selected. Correct configuration storage and retry."))?; + #[cfg(unix)] + File::open(directory) + .and_then(|file| file.sync_all()) + .map_err(|_| failure("Approved policy directory could not be synchronized. Check configuration storage."))?; + Ok(()) +} + +fn load(path: &Path) -> Result { + validate_private_file(path)?; + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + let file = options.open(path).map_err(|_| failure("Approved policy cannot be read."))?; + let mut bytes = Vec::new(); + file.take(MAX_POLICY_FILE_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| failure("Approved policy cannot be read."))?; + if bytes.len() as u64 > MAX_POLICY_FILE_BYTES { + return Err(failure( + "Approved policy storage exceeds its limit. Reset the owner in native preferences.", + )); + } + let stored: PersistedPolicy = serde_json::from_slice(&bytes) + .map_err(|_| failure("Approved policy storage is invalid. Reset the owner in native preferences."))?; + if stored.version != POLICY_VERSION { + return Err(failure( + "Approved policy storage version is unsupported. Reset the owner in native preferences.", + )); + } + if let Some(receipt) = &stored.approved { + validate_origin(&receipt.origin)?; + if receipt.companion_id != stored.companion_id + || receipt.version != POLICY_VERSION + || !(0..=MAX_WIRE_INTEGER).contains(&receipt.revision) + || receipt.backend_identity.len() != 64 + || !receipt.backend_identity.bytes().all(|byte| byte.is_ascii_hexdigit()) + || receipt.challenge.len() != 64 + || !receipt.challenge.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err(failure( + "Approved policy binding is invalid. Reset the owner in native preferences.", + )); + } + receipt + .policy + .validate() + .map_err(|_| failure("Approved policy values are invalid. Reset the owner in native preferences."))?; + } + Ok(stored) +} + +impl DirectoryPolicyState { + pub(super) fn protect_storage(&self, index_namespace: &Path) -> Result<(), ApiError> { + let configuration = self + .directory + .parent() + .ok_or_else(|| failure("Native policy storage has no configuration parent."))?; + let roots = vec![canonical_storage_path(configuration)?, canonical_storage_path(index_namespace)?]; + if let Some(existing) = PROTECTED_NATIVE_STORAGE.get() { + if existing == &roots { + return Ok(()); + } + return Err(failure( + "Policy storage protection is already bound to another directory. Restart the companion.", + )); + } + PROTECTED_NATIVE_STORAGE + .set(roots) + .map_err(|_| failure("Policy storage protection changed during initialization. Restart the companion.")) + } + + fn available_state(&self) -> Result, ApiError> { + self.state.try_lock().map_err(|error| match error { + TryLockError::WouldBlock => ApiError::TooManyRequests( + "Approved policy storage is being updated. Wait for the current operation, then refresh policy status.".to_owned(), + ), + TryLockError::Poisoned(_) => failure("Policy owner state is unavailable. Restart the companion."), + }) + } + + pub fn open(directory: PathBuf, manager: &Arc) -> Result, ApiError> { + private_directory(&directory)?; + let path = directory.join(POLICY_FILE); + let lease = super::directory_index::acquire_storage(&path).map_err(|_| failure("Approved policy storage is owned by another instance or unavailable. Close the other companion instance and check private configuration storage."))?; + let blank = || PersistedPolicy { + version: POLICY_VERSION, + companion_id: uuid::Uuid::new_v4(), + approved: None, + }; + let (stored, blocked) = if path.try_exists().map_err(|_| failure("Approved policy cannot be checked."))? { + match load(&path) { + Ok(stored) => (stored, false), + Err(_) => (blank(), true), + } + } else { + let stored = blank(); + save(&directory, &stored)?; + (stored, false) + }; + if let Some(receipt) = &stored.approved { + manager + .apply_policy(receipt.policy.clone()) + .map_err(|_| failure("Approved policy cannot be applied. Restart the companion."))?; + } + Ok(Arc::new(Self { + directory, + _lease: lease, + manager: Arc::downgrade(manager), + state: Mutex::new(PolicyState { + status: if blocked { + "failed" + } else if stored.approved.is_some() { + "applied" + } else { + "unowned" + }, + stored, + pending: None, + last_requested: None, + prompt_active: false, + message: blocked.then(|| { + "Approved policy storage is invalid. Reset the owner in native preferences before accepting another policy.".to_owned() + }), + blocked, + }), + })) + } + + pub fn status(&self, origin: Option<&str>) -> Result { + let mut state = self.available_state()?; + if state + .pending + .as_ref() + .is_some_and(|pending| pending.created.elapsed() >= CHALLENGE_TIMEOUT && !pending.redeeming) + { + state.pending = None; + state.status = if state.stored.approved.is_some() { "applied" } else { "unowned" }; + } + let receipt = state.stored.approved.as_ref(); + let permitted_pending = state.pending.as_ref().filter(|pending| origin == Some(pending.origin.as_str())); + let conflict = origin.is_some_and(|origin| receipt.is_some_and(|receipt| receipt.origin != origin)); + Ok(DirectoryPolicyStatus { + version: POLICY_VERSION, + companion_id: state.stored.companion_id, + status: if conflict { "owner_conflict" } else { state.status }, + owner_origin: receipt.map(|receipt| receipt.origin.clone()), + backend_identity: receipt.map(|receipt| receipt.backend_identity.clone()), + effective_revision: receipt.map(|receipt| receipt.revision), + policy: match receipt { + Some(receipt) => receipt.policy.clone(), + None => DirectoryPolicy::defaults().map_err(|_| failure("Default directory policy is unavailable."))?, + }, + challenge: permitted_pending.map(|pending| pending.value.clone()), + approved: permitted_pending.is_some_and(|pending| pending.approved), + message: state.message.clone(), + }) + } + + fn create_challenge(&self, origin: &str) -> Result { + validate_origin(origin)?; + let mut state = self.available_state()?; + if state.blocked { + return Err(failure("Policy ownership is blocked. Reset the owner in native preferences.")); + } + if state.prompt_active { + return Err(ApiError::TooManyRequests( + "A local policy prompt is still open. Complete or close that prompt before requesting another approval.".to_owned(), + )); + } + if state.stored.approved.as_ref().is_some_and(|owner| owner.origin != origin) { + return Err(ApiError::Conflict(serde_json::json!( + "Another backend owns directory-index policy. Reset that owner in native preferences before changing it." + ))); + } + if let Some(pending) = &state.pending { + if pending.created.elapsed() < CHALLENGE_TIMEOUT || pending.redeeming { + return Err(ApiError::TooManyRequests( + "A policy approval is already pending. Complete it or wait for expiry.".to_owned(), + )); + } + } + if state.last_requested.is_some_and(|last| last.elapsed() < APPROVAL_COOLDOWN) { + return Err(ApiError::TooManyRequests( + "Policy approval was requested too recently. Wait briefly and retry.".to_owned(), + )); + } + let approved = state.stored.approved.is_some(); + state.pending = Some(PendingChallenge { + value: format!("{}{}", uuid::Uuid::new_v4().simple(), uuid::Uuid::new_v4().simple()), + origin: origin.to_owned(), + created: Instant::now(), + approved, + redeeming: false, + }); + state.last_requested = Some(Instant::now()); + state.status = if approved { "pending_delivery" } else { "pending_local_approval" }; + state.message = None; + Ok(!approved) + } + + pub fn challenge(self: &Arc, origin: &str, app: &AppHandle) -> Result { + if self.create_challenge(origin)? { + let weak = Arc::downgrade(self); + let challenge = self + .status(Some(origin))? + .challenge + .ok_or_else(|| failure("Policy challenge is unavailable."))?; + self.state + .lock() + .map_err(|_| failure("Policy owner state is unavailable."))? + .prompt_active = true; + app.dialog() + .message(format!( + "Allow {origin} to manage directory-index resource settings on this computer? Choose No to keep the current policy." + )) + .title("Directory-Index Policy Owner") + .buttons(MessageDialogButtons::YesNo) + .show(move |approved| { + if let Some(policy) = weak.upgrade() { + if let Ok(mut state) = policy.state.lock() { + state.prompt_active = false; + if let Some(pending) = state.pending.as_mut() { + if pending.value == challenge && pending.created.elapsed() < CHALLENGE_TIMEOUT { + if approved { + pending.approved = true; + state.status = "pending_delivery"; + } else { + state.pending = None; + state.status = "denied"; + } + } + } + } + } + }); + } + self.status(Some(origin)) + } + + async fn fetch_receipt(origin: &str, companion_id: uuid::Uuid, challenge: &str, grant: &str) -> Result { + let parsed = validate_origin(origin)?; + let mut builder = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(REDEMPTION_TIMEOUT); + if parsed.scheme() == "http" { + builder = builder.no_proxy(); + if parsed.host_str() == Some("localhost") { + let port = parsed + .port_or_known_default() + .ok_or_else(|| failure("Policy origin has no valid port."))?; + builder = builder.resolve_to_addrs( + "localhost", + &[ + std::net::SocketAddr::new(std::net::Ipv4Addr::LOCALHOST.into(), port), + std::net::SocketAddr::new(std::net::Ipv6Addr::LOCALHOST.into(), port), + ], + ); + } + } + let client = builder.build().map_err(|_| failure("Policy redemption client cannot start."))?; + let mut response = client + .post(format!("{origin}/api/admin/settings/directory-index/companion/grants/redeem")) + .json(&serde_json::json!({"companion_id": companion_id, "challenge": challenge, "origin": origin, "grant": grant})) + .send() + .await + .map_err(|_| { + failure("Backend policy redemption is unavailable. Check the approved backend connection and request a new grant.") + })?; + if !response.status().is_success() { + return Err(failure("Backend refused policy redemption. Check current administrator access and request a new grant. Redirects are not permitted.")); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| failure("Backend policy receipt could not be read. Request a new grant."))? + { + if chunk.len() > MAX_RESPONSE_BYTES.saturating_sub(bytes.len()) { + return Err(failure("Backend policy receipt exceeds its response limit.")); + } + bytes.extend_from_slice(&chunk); + } + serde_json::from_slice(&bytes) + .map_err(|_| failure("Backend policy receipt is invalid. Request a new grant from a compatible backend.")) + } + + fn persist_with_recovery(&self, state: &mut PolicyState, stored: &PersistedPolicy) -> Result<(), super::directory_index::IndexError> { + if save(&self.directory, stored).is_err() { + if save(&self.directory, &state.stored).is_err() { + state.blocked = true; + state.status = "failed"; + state.message = Some( + "Policy storage could not be restored. Correct configuration storage, then reset the owner in native preferences." + .to_owned(), + ); + } + return Err(super::directory_index::IndexError::Invalid( + "Approved directory policy cannot be stored durably. Correct configuration storage and retry.", + )); + } + Ok(()) + } + + fn select_receipt(&self, receipt: PolicyReceipt) -> Result<(), ApiError> { + let mut state = self.state.lock().map_err(|_| failure("Policy owner state is unavailable."))?; + let pending = state + .pending + .as_ref() + .ok_or_else(|| failure("Policy approval was reset before redemption completed."))?; + if pending.value != receipt.challenge + || pending.origin != receipt.origin + || !pending.redeeming + || !pending.approved + || pending.created.elapsed() >= CHALLENGE_TIMEOUT + { + return Err(failure("Policy approval was replaced or expired before redemption completed.")); + } + if receipt.version != POLICY_VERSION + || receipt.companion_id != state.stored.companion_id + || !(0..=MAX_WIRE_INTEGER).contains(&receipt.revision) + || receipt.backend_identity.len() != 64 + || !receipt.backend_identity.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err(failure( + "Backend policy receipt does not match this companion. Request a new grant.", + )); + } + receipt + .policy + .validate() + .map_err(|_| failure("Backend policy contains unsupported resource values."))?; + if let Some(owner) = &state.stored.approved { + if owner.origin != receipt.origin || owner.backend_identity != receipt.backend_identity || receipt.revision < owner.revision { + return Err(ApiError::Conflict(serde_json::json!("Policy owner identity changed or its revision moved backwards. Reset the owner in native preferences before accepting it."))); + } + if owner.revision == receipt.revision && owner.policy != receipt.policy { + return Err(failure("Backend returned different policy values for the same revision. Request a new grant after correcting the backend policy.")); + } + } + let stored = PersistedPolicy { + version: POLICY_VERSION, + companion_id: state.stored.companion_id, + approved: Some(receipt.clone()), + }; + let manager = self + .manager + .upgrade() + .ok_or_else(|| failure("Directory manager is unavailable. Restart the companion."))?; + manager + .apply_policy_with_persistence(receipt.policy, || self.persist_with_recovery(&mut state, &stored)) + .map_err(|error| failure(&format!("Approved policy was not applied. {error}")))?; + state.stored = stored; + state.pending = None; + state.status = "applied"; + state.message = None; + Ok(()) + } + + pub async fn redeem(self: &Arc, origin: &str, delivery: PolicyGrantDelivery) -> Result { + if delivery.grant.is_empty() || delivery.grant.len() > MAX_GRANT_BYTES { + return Err(failure("Policy grant size is invalid. Request a new grant.")); + } + let (companion_id, challenge) = { + let mut state = self.available_state()?; + let companion_id = state.stored.companion_id; + let pending = state + .pending + .as_mut() + .ok_or_else(|| failure("No policy challenge is pending. Request approval first."))?; + if pending.origin != origin || !pending.approved || pending.created.elapsed() >= CHALLENGE_TIMEOUT || pending.redeeming { + return Err(ApiError::Conflict(serde_json::json!("Policy challenge is not approved, expired, or already being redeemed. Complete local approval or request a new challenge."))); + } + pending.redeeming = true; + let challenge = pending.value.clone(); + state.status = "redeeming"; + (companion_id, challenge) + }; + let policy = Arc::clone(self); + let origin = origin.to_owned(); + let ownership = PolicyRedemptionOwnership { + policy: Arc::clone(&policy), + challenge: challenge.clone(), + }; + tokio::spawn(async move { + let _ownership = ownership; + let outcome = match Self::fetch_receipt(&origin, companion_id, &challenge, &delivery.grant).await { + Ok(receipt) => { + let selection = Arc::clone(&policy); + match tokio::task::spawn_blocking(move || selection.select_receipt(receipt)).await { + Ok(outcome) => outcome, + Err(_) => Err(failure("Policy storage worker failed. Restart the companion.")), + } + } + Err(error) => Err(error), + }; + if let Err(error) = outcome { + let mut state = policy.state.lock().map_err(|_| failure("Policy owner state is unavailable."))?; + if state.pending.as_ref().is_some_and(|pending| pending.value == challenge) { + state.pending = None; + state.status = "failed"; + if !state.blocked { + state.message = Some( + "Policy redemption failed. Existing approved policy remains active. Request a new approval grant.".to_owned(), + ); + } + } + return Err(error); + } + policy.status(Some(&origin)) + }) + .await + .map_err(|_| failure("Policy redemption worker failed. Restart the companion."))? + } + + fn reset(&self) -> Result<(), ApiError> { + let mut state = self.state.lock().map_err(|_| failure("Policy owner state is unavailable."))?; + let manager = self + .manager + .upgrade() + .ok_or_else(|| failure("Directory manager is unavailable. Restart the companion before resetting its policy owner."))?; + let defaults = DirectoryPolicy::defaults().map_err(|_| failure("Default policy is unavailable."))?; + let stored = PersistedPolicy { + version: POLICY_VERSION, + companion_id: state.stored.companion_id, + approved: None, + }; + manager + .apply_policy_with_persistence(defaults, || self.persist_with_recovery(&mut state, &stored)) + .map_err(|error| failure(&format!("Default policy was not applied. {error}")))?; + state.stored = stored; + state.pending = None; + state.status = "unowned"; + state.blocked = false; + state.message = None; + Ok(()) + } +} + +#[tauri::command] +pub fn get_native_directory_policy_status(app: AppHandle) -> Result { + app.try_state::>() + .ok_or_else(|| "Directory policy is not ready. Wait briefly and retry.".to_owned())? + .status(None) + .map_err(|_| "Directory policy state is unavailable. Restart the companion.".to_owned()) +} + +#[tauri::command] +pub async fn reset_native_directory_policy_owner(window: tauri::WebviewWindow, app: AppHandle) -> Result<(), String> { + if window.label() != crate::MAIN_WINDOW_LABEL { + return Err("Policy owner reset is available only in native preferences.".to_owned()); + } + let managed = app + .try_state::>() + .ok_or_else(|| "Directory policy is not ready. Wait briefly and retry.".to_owned())?; + let policy = Arc::clone(managed.inner()); + tokio::task::spawn_blocking(move || policy.reset()) + .await + .map_err(|_| "Policy owner reset worker failed. Restart the companion.".to_owned())? + .map_err(|_| "Policy owner could not be reset. Check private configuration storage.".to_owned()) +} + +#[cfg(test)] +mod tests { + #[tokio::test] + async fn failed_policy_storage_preserves_old_owner_and_blocks_ambiguous_delivery() { + let temporary = tempfile::tempdir().unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("cache")).unwrap(); + let directory = temporary.path().join("configuration"); + let policy = DirectoryPolicyState::open(directory.clone(), &manager).unwrap(); + let before = std::fs::read(directory.join(POLICY_FILE)).unwrap(); + policy.create_challenge("https://sambee.example").unwrap(); + let (companion_id, challenge) = { + let mut state = policy.state.lock().unwrap(); + let pending = state.pending.as_mut().unwrap(); + pending.approved = true; + pending.redeeming = true; + let challenge = pending.value.clone(); + (state.stored.companion_id, challenge) + }; + std::fs::create_dir(directory.join(POLICY_STAGE_FILE)).unwrap(); + let mut proposed = DirectoryPolicy::defaults().unwrap(); + proposed.page_cache_kib = 64; + assert!(policy + .select_receipt(PolicyReceipt { + version: POLICY_VERSION, + companion_id, + challenge, + origin: "https://sambee.example".to_owned(), + backend_identity: "b".repeat(64), + revision: 1, + policy: proposed + }) + .is_err()); + assert_eq!(std::fs::read(directory.join(POLICY_FILE)).unwrap(), before); + let state = policy.state.lock().unwrap(); + assert!(state.blocked); + assert!(state.stored.approved.is_none()); + assert!(state.message.as_deref().unwrap().contains("Correct configuration storage")); + } + + #[tokio::test] + async fn unavailable_manager_cannot_clear_durable_policy_owner() { + let temporary = tempfile::tempdir().unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("cache")).unwrap(); + let path = temporary.path().join("configuration"); + let policy = DirectoryPolicyState::open(path.clone(), &manager).unwrap(); + let stored = PersistedPolicy { + version: POLICY_VERSION, + companion_id: policy.status(None).unwrap().companion_id, + approved: Some(PolicyReceipt { + version: POLICY_VERSION, + companion_id: policy.status(None).unwrap().companion_id, + challenge: "a".repeat(64), + origin: "https://sambee.example".to_owned(), + backend_identity: "b".repeat(64), + revision: 1, + policy: DirectoryPolicy::defaults().unwrap(), + }), + }; + save(&path, &stored).unwrap(); + policy.state.lock().unwrap().stored = stored; + let before = std::fs::read(path.join(POLICY_FILE)).unwrap(); + drop(manager); + assert!(policy.reset().is_err()); + assert_eq!(std::fs::read(path.join(POLICY_FILE)).unwrap(), before); + assert_eq!(policy.status(None).unwrap().owner_origin.as_deref(), Some("https://sambee.example")); + } + + use super::*; + + #[test] + fn origins_require_exact_https_or_loopback_http() { + for origin in [ + "https://sambee.example", + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://[::1]:3000", + ] { + assert!(validate_origin(origin).is_ok()); + } + for origin in [ + "http://remote.example", + "https://user:password@sambee.example", + "https://sambee.example/path", + "https://sambee.example?query=1", + "https://sambee.example/", + ] { + assert!(validate_origin(origin).is_err()); + } + } + + #[test] + fn deliveries_reject_browser_policy_and_origin_extras() { + assert!( + serde_json::from_value::(serde_json::json!({"grant": "token", "policy": {"disk_budget_bytes": 1}})) + .is_err() + ); + assert!( + serde_json::from_value::(serde_json::json!({"grant": "token", "origin": "https://other.example"})) + .is_err() + ); + } + + #[cfg(windows)] + #[test] + fn windows_storage_components_use_ordinal_unicode_case_comparison() { + let protected = Path::new("C:\\Users\\\u{00c5}sa\\cache"); + assert!(storage_path_starts_with(Path::new("c:\\users\\\u{00e5}SA\\CACHE\\new"), protected).unwrap()); + assert!(!storage_path_starts_with(Path::new("C:\\Users\\\u{00e5}sa\\cache-other"), protected).unwrap()); + assert!(storage_path_starts_with(protected, Path::new("c:\\USERS\\\u{00e5}sa")).unwrap()); + } + + #[cfg(windows)] + #[test] + fn windows_unicode_case_alias_cannot_mutate_native_storage() { + let temporary = tempfile::tempdir().unwrap(); + let protected = temporary.path().join("\u{00c5}policy"); + std::fs::create_dir(&protected).unwrap(); + let alias = temporary.path().join("\u{00e5}POLICY").join("new.json"); + assert!(ensure_outside_policy_storage(&alias, &protected).is_err()); + } + + #[test] + fn file_mutations_cannot_replace_policy_files_or_their_parent_directories() { + let temporary = tempfile::tempdir().unwrap(); + let protected = temporary.path().join("private-policy"); + std::fs::create_dir(&protected).unwrap(); + std::fs::write(protected.join(POLICY_FILE), b"approved").unwrap(); + let protected = protected.canonicalize().unwrap(); + for path in [ + protected.clone(), + protected.join(POLICY_FILE), + protected.join("new/receipt.json"), + temporary.path().to_path_buf(), + ] { + assert!(ensure_outside_policy_storage(&path, &protected).is_err()); + } + assert!(ensure_outside_policy_storage(&temporary.path().join("ordinary/new.json"), &protected).is_ok()); + assert!(ensure_outside_policy_storage(&temporary.path().join("private-policy-other"), &protected).is_ok()); + #[cfg(unix)] + { + let alias = temporary.path().join("alias"); + std::os::unix::fs::symlink(&protected, &alias).unwrap(); + assert!(ensure_outside_policy_storage(&alias.join(POLICY_FILE), &protected).is_err()); + assert!(ensure_outside_policy_storage(&alias.join("new/receipt.json"), &protected).is_err()); + } + } + + #[tokio::test] + async fn native_storage_binding_protects_configuration_and_index_database_paths() { + let temporary = tempfile::tempdir().unwrap(); + let namespace = temporary.path().join("index-cache"); + let manager = LocalDirectoryManager::new(namespace.clone()).unwrap(); + let configuration = temporary.path().join("configuration"); + let policy = DirectoryPolicyState::open(configuration.join("policy"), &manager).unwrap(); + policy.protect_storage(&namespace).unwrap(); + for path in [ + configuration.join("preferences.json"), + policy.directory.join(POLICY_FILE), + namespace.join("source.sqlite3"), + namespace.clone(), + ] { + assert!(ensure_mutation_allowed(&path).is_err()); + } + assert!(ensure_mutation_allowed(&temporary.path().join("ordinary.txt")).is_ok()); + #[cfg(windows)] + assert!(ensure_mutation_allowed(&PathBuf::from(namespace.to_str().unwrap().to_ascii_uppercase())).is_err()); + } + + #[tokio::test] + async fn native_owner_is_single_and_pending_approvals_are_bounded() { + let temporary = tempfile::tempdir().unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("cache")).unwrap(); + let policy = DirectoryPolicyState::open(temporary.path().join("configuration"), &manager).unwrap(); + assert!(policy.create_challenge("https://sambee.example").unwrap()); + assert!(policy.create_challenge("https://other.example").is_err()); + let status = policy.status(Some("https://sambee.example")).unwrap(); + assert_eq!(status.status, "pending_local_approval"); + assert!(!status.approved); + assert!(policy.status(Some("https://other.example")).unwrap().challenge.is_none()); + assert!(policy + .redeem("https://sambee.example", PolicyGrantDelivery { grant: "token".into() }) + .await + .is_err()); + } + + #[tokio::test] + async fn policy_storage_lease_is_held_until_the_actual_owner_exits() { + let temporary = tempfile::tempdir().unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("cache")).unwrap(); + let path = temporary.path().join("configuration"); + let policy = DirectoryPolicyState::open(path.clone(), &manager).unwrap(); + let retained = Arc::clone(&policy); + assert!(DirectoryPolicyState::open(path.clone(), &manager).is_err()); + drop(policy); + assert!(DirectoryPolicyState::open(path.clone(), &manager).is_err()); + drop(retained); + assert!(DirectoryPolicyState::open(path, &manager).is_ok()); + } + + #[tokio::test] + async fn status_and_approval_do_not_wait_for_a_blocked_storage_writer() { + let temporary = tempfile::tempdir().unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("cache")).unwrap(); + let policy = DirectoryPolicyState::open(temporary.path().join("configuration"), &manager).unwrap(); + let held = policy.state.lock().unwrap(); + assert!(matches!(policy.status(None), Err(ApiError::TooManyRequests(_)))); + assert!(matches!( + policy.create_challenge("https://sambee.example"), + Err(ApiError::TooManyRequests(_)) + )); + drop(held); + assert!(policy.status(None).is_ok()); + } + + #[tokio::test] + async fn expired_challenge_does_not_release_an_outstanding_native_prompt() { + let temporary = tempfile::tempdir().unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("cache")).unwrap(); + let policy = DirectoryPolicyState::open(temporary.path().join("configuration"), &manager).unwrap(); + policy.create_challenge("https://sambee.example").unwrap(); + { + let mut state = policy.state.lock().unwrap(); + state.pending.as_mut().unwrap().created -= CHALLENGE_TIMEOUT; + state.last_requested = None; + state.prompt_active = true; + } + assert!(policy.status(None).unwrap().challenge.is_none()); + assert!(policy.create_challenge("https://sambee.example").is_err()); + policy.reset().unwrap(); + assert!(policy.create_challenge("https://sambee.example").is_err()); + policy.state.lock().unwrap().prompt_active = false; + assert!(policy.create_challenge("https://sambee.example").unwrap()); + } + + #[tokio::test] + async fn persisted_owner_and_policy_survive_restart_and_native_reset() { + let temporary = tempfile::tempdir().unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("cache")).unwrap(); + let path = temporary.path().join("configuration"); + let policy = DirectoryPolicyState::open(path.clone(), &manager).unwrap(); + let companion_id = policy.status(None).unwrap().companion_id; + let receipt = PolicyReceipt { + version: POLICY_VERSION, + companion_id, + challenge: "a".repeat(64), + origin: "https://sambee.example".into(), + backend_identity: "b".repeat(64), + revision: 7, + policy: DirectoryPolicy::defaults().unwrap(), + }; + save( + &path, + &PersistedPolicy { + version: POLICY_VERSION, + companion_id, + approved: Some(receipt), + }, + ) + .unwrap(); + drop(policy); + let reopened = DirectoryPolicyState::open(path, &manager).unwrap(); + assert_eq!(reopened.status(None).unwrap().effective_revision, Some(7)); + assert!(reopened.create_challenge("https://other.example").is_err()); + reopened.reset().unwrap(); + assert_eq!(reopened.status(None).unwrap().owner_origin, None); + assert_eq!(reopened.status(None).unwrap().companion_id, companion_id); + } + + #[tokio::test] + async fn invalid_stored_policy_requires_native_reset_without_overwriting_it() { + let temporary = tempfile::tempdir().unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("cache")).unwrap(); + let path = temporary.path().join("configuration"); + let policy = DirectoryPolicyState::open(path.clone(), &manager).unwrap(); + drop(policy); + std::fs::write(path.join(POLICY_FILE), b"invalid policy").unwrap(); + let reopened = DirectoryPolicyState::open(path.clone(), &manager).unwrap(); + assert_eq!(reopened.status(None).unwrap().status, "failed"); + assert!(reopened.create_challenge("https://sambee.example").is_err()); + assert_eq!(std::fs::read(path.join(POLICY_FILE)).unwrap(), b"invalid policy"); + reopened.reset().unwrap(); + assert_eq!(reopened.status(None).unwrap().status, "unowned"); + } + + #[tokio::test] + async fn direct_receipt_is_applied_only_after_local_approval() { + let temporary = tempfile::tempdir().unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("cache")).unwrap(); + let policy = DirectoryPolicyState::open(temporary.path().join("configuration"), &manager).unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://localhost:{}", listener.local_addr().unwrap().port()); + policy.create_challenge(&origin).unwrap(); + let status = policy.status(Some(&origin)).unwrap(); + let mut values = DirectoryPolicy::defaults().unwrap(); + values.page_cache_kib = 1024; + let receipt = PolicyReceipt { + version: POLICY_VERSION, + companion_id: status.companion_id, + challenge: status.challenge.unwrap(), + origin: origin.clone(), + backend_identity: "b".repeat(64), + revision: 3, + policy: values.clone(), + }; + let server = tokio::spawn(async move { + let router = axum::Router::new().route( + "/api/admin/settings/directory-index/companion/grants/redeem", + axum::routing::post(move || { + let receipt = receipt.clone(); + async move { axum::Json(receipt) } + }), + ); + axum::serve(listener, router).await.unwrap(); + }); + assert!(policy + .redeem(&origin, PolicyGrantDelivery { grant: "opaque".into() }) + .await + .is_err()); + policy.state.lock().unwrap().pending.as_mut().unwrap().approved = true; + let applied = policy + .redeem(&origin, PolicyGrantDelivery { grant: "opaque".into() }) + .await + .unwrap(); + assert_eq!(applied.effective_revision, Some(3)); + assert_eq!(applied.policy, values); + assert_eq!(applied.status, "applied"); + server.abort(); + } + + #[tokio::test] + async fn redirects_are_not_followed_and_failed_delivery_keeps_policy() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let hits = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let redirect_hits = Arc::clone(&hits); + let target = format!("{origin}/redirected"); + let server = tokio::spawn(async move { + let router = axum::Router::new() + .route( + "/api/admin/settings/directory-index/companion/grants/redeem", + axum::routing::post(move || { + let target = target.clone(); + async move { axum::response::Redirect::temporary(&target) } + }), + ) + .route( + "/redirected", + axum::routing::get(move || { + redirect_hits.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + async { "unsafe" } + }), + ); + axum::serve(listener, router).await.unwrap(); + }); + let result = DirectoryPolicyState::fetch_receipt(&origin, uuid::Uuid::new_v4(), &"a".repeat(64), "opaque").await; + assert!(result.is_err()); + assert_eq!(hits.load(std::sync::atomic::Ordering::SeqCst), 0); + server.abort(); + } + + #[tokio::test] + async fn cancelled_http_request_does_not_cancel_admitted_redemption() { + let temporary = tempfile::tempdir().unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("cache")).unwrap(); + let policy = DirectoryPolicyState::open(temporary.path().join("configuration"), &manager).unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + policy.create_challenge(&origin).unwrap(); + policy.state.lock().unwrap().pending.as_mut().unwrap().approved = true; + let status = policy.status(Some(&origin)).unwrap(); + let receipt = PolicyReceipt { + version: POLICY_VERSION, + companion_id: status.companion_id, + challenge: status.challenge.unwrap(), + origin: origin.clone(), + backend_identity: "b".repeat(64), + revision: 3, + policy: DirectoryPolicy::defaults().unwrap(), + }; + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let server_entered = Arc::clone(&entered); + let server_release = Arc::clone(&release); + let server = tokio::spawn(async move { + let router = axum::Router::new().route( + "/api/admin/settings/directory-index/companion/grants/redeem", + axum::routing::post(move || { + let entered = Arc::clone(&server_entered); + let release = Arc::clone(&server_release); + let receipt = receipt.clone(); + async move { + entered.notify_one(); + release.notified().await; + axum::Json(receipt) + } + }), + ); + axum::serve(listener, router).await.unwrap(); + }); + let request_policy = Arc::clone(&policy); + let request = tokio::spawn(async move { request_policy.redeem(&origin, PolicyGrantDelivery { grant: "opaque".into() }).await }); + entered.notified().await; + request.abort(); + release.notify_one(); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + match policy.status(None) { + Ok(status) if status.status == "applied" => break, + Ok(_) | Err(ApiError::TooManyRequests(_)) => tokio::task::yield_now().await, + Err(error) => panic!("Unexpected policy status: {error:?}"), + } + } + }) + .await + .unwrap(); + assert_eq!(policy.status(None).unwrap().effective_revision, Some(3)); + server.abort(); + } + + #[tokio::test] + async fn oversized_receipt_is_refused_and_ownership_cleans_up() { + let temporary = tempfile::tempdir().unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("cache")).unwrap(); + let policy = DirectoryPolicyState::open(temporary.path().join("configuration"), &manager).unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + policy.create_challenge(&origin).unwrap(); + policy.state.lock().unwrap().pending.as_mut().unwrap().approved = true; + let server = tokio::spawn(async move { + let router = axum::Router::new().route( + "/api/admin/settings/directory-index/companion/grants/redeem", + axum::routing::post(|| async { "x".repeat(MAX_RESPONSE_BYTES + 1) }), + ); + axum::serve(listener, router).await.unwrap(); + }); + assert!(policy + .redeem(&origin, PolicyGrantDelivery { grant: "opaque".into() }) + .await + .is_err()); + assert_eq!(policy.status(None).unwrap().status, "failed"); + assert!(policy.state.lock().unwrap().pending.is_none()); + assert!(policy.status(None).unwrap().owner_origin.is_none()); + server.abort(); + } + + #[tokio::test] + async fn invalid_bindings_and_changed_values_at_the_same_revision_are_refused() { + let temporary = tempfile::tempdir().unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("cache")).unwrap(); + let policy = DirectoryPolicyState::open(temporary.path().join("configuration"), &manager).unwrap(); + let origin = "https://sambee.example"; + policy.create_challenge(origin).unwrap(); + { + let mut state = policy.state.lock().unwrap(); + let pending = state.pending.as_mut().unwrap(); + pending.approved = true; + pending.redeeming = true; + } + let status = policy.status(Some(origin)).unwrap(); + let receipt = PolicyReceipt { + version: POLICY_VERSION, + companion_id: status.companion_id, + challenge: status.challenge.unwrap(), + origin: origin.into(), + backend_identity: "a".repeat(64), + revision: 3, + policy: DirectoryPolicy::defaults().unwrap(), + }; + let mut wrong_companion = receipt.clone(); + wrong_companion.companion_id = uuid::Uuid::new_v4(); + let mut wrong_challenge = receipt.clone(); + wrong_challenge.challenge = "b".repeat(64); + let mut wrong_origin = receipt.clone(); + wrong_origin.origin = "https://other.example".into(); + let mut wrong_identity = receipt.clone(); + wrong_identity.backend_identity = "z".repeat(64); + let mut unsafe_revision = receipt.clone(); + unsafe_revision.revision = MAX_WIRE_INTEGER + 1; + let mut invalid_policy = receipt.clone(); + invalid_policy.policy.page_cache_kib = 1; + let mut wrong_version = receipt.clone(); + wrong_version.version = POLICY_VERSION + 1; + for invalid in [ + wrong_companion, + wrong_challenge, + wrong_origin, + wrong_identity, + unsafe_revision, + invalid_policy, + wrong_version, + ] { + assert!(policy.select_receipt(invalid).is_err()); + assert!(load(&policy.directory.join(POLICY_FILE)).unwrap().approved.is_none()); + } + policy.select_receipt(receipt.clone()).unwrap(); + policy.state.lock().unwrap().last_requested = None; + policy.create_challenge(origin).unwrap(); + let mut changed = receipt.clone(); + changed.challenge = policy.status(Some(origin)).unwrap().challenge.unwrap(); + changed.policy.page_cache_kib = 1024; + policy.state.lock().unwrap().pending.as_mut().unwrap().redeeming = true; + assert!(policy.select_receipt(changed).is_err()); + assert_eq!( + load(&policy.directory.join(POLICY_FILE)).unwrap().approved.unwrap().policy, + receipt.policy + ); + let mut newer = receipt.clone(); + newer.challenge = policy.status(Some(origin)).unwrap().challenge.unwrap(); + newer.revision += 1; + policy.select_receipt(newer.clone()).unwrap(); + policy.state.lock().unwrap().last_requested = None; + policy.create_challenge(origin).unwrap(); + let mut late = receipt.clone(); + late.challenge = policy.status(Some(origin)).unwrap().challenge.unwrap(); + policy.state.lock().unwrap().pending.as_mut().unwrap().redeeming = true; + assert!(policy.select_receipt(late.clone()).is_err()); + assert_eq!(policy.status(Some(origin)).unwrap().effective_revision, Some(newer.revision)); + assert_eq!( + load(&policy.directory.join(POLICY_FILE)).unwrap().approved.unwrap().revision, + newer.revision + ); + policy.reset().unwrap(); + assert!(policy.select_receipt(late).is_err()); + assert!(policy.status(Some(origin)).unwrap().owner_origin.is_none()); + assert_eq!(policy.status(Some(origin)).unwrap().policy, DirectoryPolicy::defaults().unwrap()); + } +} diff --git a/companion/src-tauri/src/server/directory_removal_windows.rs b/companion/src-tauri/src/server/directory_removal_windows.rs new file mode 100644 index 000000000..43f2942db --- /dev/null +++ b/companion/src-tauri/src/server/directory_removal_windows.rs @@ -0,0 +1,244 @@ +use std::fs::File; +use std::os::windows::{ffi::OsStrExt, fs::OpenOptionsExt, io::AsRawHandle}; +use std::sync::{mpsc, Arc, Weak}; + +use windows::core::{w, PCWSTR}; +use windows::Win32::Foundation::{GetLastError, ERROR_CLASS_ALREADY_EXISTS, HANDLE, HWND, LPARAM, LRESULT, WPARAM}; +use windows::Win32::Storage::FileSystem::{GetVolumePathNameW, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE}; +use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::UI::WindowsAndMessaging::*; + +use super::{LocalSource, STALLED_READ_TIMEOUT}; + +pub(super) struct RemovalWatch { + window: usize, + #[cfg(test)] + volume: usize, +} + +struct Context { + source: Weak, + volume: Option, + notification: Option, +} + +impl Context { + fn release(&mut self) { + if let Some(notification) = self.notification.take() { + if unsafe { UnregisterDeviceNotification(notification) }.is_err() { + log::warn!("Directory removal notification could not be released. Restart the companion before removing the drive."); + } + } + self.volume.take(); + } +} + +impl Drop for Context { + fn drop(&mut self) { + self.release(); + } +} + +unsafe extern "system" fn procedure(window: HWND, message: u32, parameter: WPARAM, data: LPARAM) -> LRESULT { + if message == WM_NCCREATE { + let creation = &*(data.0 as *const CREATESTRUCTW); + SetWindowLongPtrW(window, GWLP_USERDATA, creation.lpCreateParams as isize); + } + let pointer = GetWindowLongPtrW(window, GWLP_USERDATA) as *mut Context; + if message == WM_DEVICECHANGE && !pointer.is_null() && data.0 != 0 { + let context = &mut *pointer; + let header = &*(data.0 as *const DEV_BROADCAST_HDR); + if header.dbch_devicetype == DBT_DEVTYP_HANDLE && header.dbch_size as usize >= size_of::() { + let event = &*(data.0 as *const DEV_BROADCAST_HANDLE); + let matches = context + .volume + .as_ref() + .is_some_and(|volume| event.dbch_handle == HANDLE(volume.as_raw_handle())); + if matches && parameter.0 as u32 == DBT_DEVICEQUERYREMOVE { + let ready = context + .source + .upgrade() + .is_none_or(|source| source.pause_for_removal().unwrap_or(false)); + if !ready { + return LRESULT(BROADCAST_QUERY_DENY as isize); + } + context.release(); + return LRESULT(1); + } + if matches && parameter.0 as u32 == DBT_DEVICEREMOVECOMPLETE { + if let Some(source) = context.source.upgrade() { + if source.release_media_handles().is_err() { + log::warn!("Directory handles could not be released after media removal. Restart the companion."); + } + } + context.release(); + return LRESULT(1); + } + } + } + if message == WM_DESTROY { + PostQuitMessage(0); + return LRESULT(0); + } + DefWindowProcW(window, message, parameter, data) +} + +impl RemovalWatch { + pub(super) fn new(source: &Arc) -> std::io::Result { + const VOLUME_PATH_UNITS: usize = 32768; + let root = source.root_path.clone(); + let source = Arc::downgrade(source); + let (sender, receiver) = mpsc::sync_channel(1); + std::thread::Builder::new().name("directory-removal".into()).spawn(move || { + let outcome = (|| -> std::io::Result<()> { + let root: Vec = root.as_os_str().encode_wide().chain(Some(0)).collect(); + let mut mount = vec![0u16; VOLUME_PATH_UNITS]; + unsafe { GetVolumePathNameW(PCWSTR(root.as_ptr()), &mut mount) }.map_err(std::io::Error::from)?; + let end = mount + .iter() + .position(|unit| *unit == 0) + .ok_or_else(|| std::io::Error::other("Invalid removal volume path."))?; + let mount = String::from_utf16(&mount[..end]).map_err(std::io::Error::other)?; + let volume = std::fs::OpenOptions::new() + .access_mode(0) + .share_mode(FILE_SHARE_READ.0 | FILE_SHARE_WRITE.0 | FILE_SHARE_DELETE.0) + .open(format!("\\\\.\\{}", mount.trim_end_matches('\\')))?; + let mut context = Box::new(Context { + source, + volume: Some(volume), + notification: None, + }); + let instance = unsafe { GetModuleHandleW(None) }.map_err(std::io::Error::from)?; + let class = WNDCLASSW { + lpfnWndProc: Some(procedure), + hInstance: instance.into(), + lpszClassName: w!("SambeeDirectoryRemoval"), + ..Default::default() + }; + if unsafe { RegisterClassW(&class) } == 0 && unsafe { GetLastError() } != ERROR_CLASS_ALREADY_EXISTS { + return Err(std::io::Error::last_os_error()); + } + let window = unsafe { + CreateWindowExW( + WINDOW_EX_STYLE::default(), + w!("SambeeDirectoryRemoval"), + w!(""), + WINDOW_STYLE::default(), + 0, + 0, + 0, + 0, + Some(HWND_MESSAGE), + None, + Some(instance.into()), + Some(std::ptr::from_mut(context.as_mut()).cast()), + ) + } + .map_err(std::io::Error::from)?; + let filter = DEV_BROADCAST_HANDLE { + dbch_size: size_of::() as u32, + dbch_devicetype: DBT_DEVTYP_HANDLE.0, + dbch_handle: HANDLE( + context + .volume + .as_ref() + .ok_or_else(|| std::io::Error::other("Removal volume handle is unavailable."))? + .as_raw_handle(), + ), + ..Default::default() + }; + match unsafe { + RegisterDeviceNotificationW(HANDLE(window.0), std::ptr::from_ref(&filter).cast(), DEVICE_NOTIFY_WINDOW_HANDLE) + } { + Ok(notification) => context.notification = Some(notification), + Err(error) => { + let _ = unsafe { DestroyWindow(window) }; + return Err(error.into()); + } + } + let volume = context + .volume + .as_ref() + .ok_or_else(|| std::io::Error::other("Removal volume handle is unavailable."))? + .as_raw_handle() as usize; + if sender.send(Ok((window.0 as usize, volume))).is_err() { + let _ = unsafe { DestroyWindow(window) }; + return Ok(()); + } + let mut message = MSG::default(); + loop { + let result = unsafe { GetMessageW(&mut message, None, 0, 0) }.0; + if result <= 0 { + break; + } + let _ = unsafe { TranslateMessage(&message) }; + unsafe { DispatchMessageW(&message) }; + } + Ok(()) + })(); + if let Err(error) = outcome { + let _ = sender.send(Err(error)); + } + })?; + let (window, _volume) = receiver + .recv_timeout(STALLED_READ_TIMEOUT) + .map_err(|_| std::io::Error::other("Directory removal monitoring did not start. Reopen the drive and retry."))??; + Ok(Self { + window, + #[cfg(test)] + volume: _volume, + }) + } +} + +impl Drop for RemovalWatch { + fn drop(&mut self) { + if unsafe { PostMessageW(Some(HWND(self.window as *mut _)), WM_CLOSE, WPARAM(0), LPARAM(0)) }.is_err() { + log::warn!("Directory removal monitor did not close. Restart the companion before removing the drive."); + } + } +} + +#[cfg(test)] +mod tests { + use super::super::{lock, LocalDirectoryManager}; + use super::*; + use std::sync::atomic::{AtomicBool, Ordering}; + + #[test] + fn query_remove_message_releases_handles_and_retains_rows() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("root"); + std::fs::create_dir_all(root.join("retained")).unwrap(); + let manager = LocalDirectoryManager::new(temporary.path().join("private")).unwrap(); + let source = LocalSource::new(root, manager.storage_directory()); + source.scan(&manager.budget).unwrap(); + let monitor = RemovalWatch::new(&source).unwrap(); + let event = DEV_BROADCAST_HANDLE { + dbch_size: size_of::() as u32, + dbch_devicetype: DBT_DEVTYP_HANDLE.0, + dbch_handle: HANDLE(monitor.volume as *mut _), + ..Default::default() + }; + let result = unsafe { + SendMessageW( + HWND(monitor.window as *mut _), + WM_DEVICECHANGE, + Some(WPARAM(DBT_DEVICEQUERYREMOVE as usize)), + Some(LPARAM(std::ptr::from_ref(&event) as isize)), + ) + }; + assert_eq!(result.0, 1); + assert!(source.media_unavailable.load(Ordering::Acquire)); + assert!(lock(&source.root).unwrap().is_none()); + assert!(lock(&source.watcher).unwrap().is_none()); + let reader = lock(&source.reader).unwrap().as_ref().unwrap().clone(); + assert_eq!( + reader + .search("retained", 5, false, &Arc::new(AtomicBool::new(false)), false) + .unwrap() + .directory_count, + 1 + ); + } +} diff --git a/companion/src-tauri/src/server/directory_traversal.rs b/companion/src-tauri/src/server/directory_traversal.rs new file mode 100644 index 000000000..c387219e2 --- /dev/null +++ b/companion/src-tauri/src/server/directory_traversal.rs @@ -0,0 +1,840 @@ +//! Descriptor-based traversal; unsupported platforms fail closed without a lossy walker. + +use std::path::Path; + +use super::directory_index::IndexError; + +pub(super) const MOUNT_BOUNDARY_CAPABILITY_ERROR: &str = + "The kernel cannot check directory mount boundaries. Restart the companion after enabling statx mount IDs or /proc/self/fdinfo."; + +fn validate_traversal_path(path: &Path) -> Result<(), IndexError> { + let maximum_bytes = super::directory_manager::limit("max_source_path_bytes")?; + let maximum_depth = super::directory_manager::limit("max_source_depth")?; + if path.as_os_str().as_encoded_bytes().len() > maximum_bytes + || path + .components() + .filter(|component| matches!(component, std::path::Component::Normal(_))) + .count() + > maximum_depth + { + return Err(IndexError::Invalid( + "Directory path exceeds supported traversal bounds. Shorten the path or reduce nesting, then reopen the drive.", + )); + } + Ok(()) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TraversalIssue { + AccessDenied, + UnsupportedEncoding, + UnsafeTraversal, + ScanFailed, +} + +impl TraversalIssue { + pub fn code(self) -> &'static str { + match self { + Self::AccessDenied => "access_denied", + Self::UnsupportedEncoding => "unsupported_encoding", + Self::UnsafeTraversal => "unsafe_traversal", + Self::ScanFailed => "scan_failed", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize)] +pub struct NativeIdentity { + device: u64, + inode: u64, + mount: u64, +} + +#[cfg(any(windows, test))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum WindowsReparseKind { + Ordinary, + Redirection, + CloudPlaceholder, + Unsupported, +} + +#[cfg(any(windows, test))] +fn windows_reparse_kind(tag: u32) -> WindowsReparseKind { + const NAME_SURROGATE: u32 = 0x2000_0000; + const CLOUD_TAG: u32 = 0x9000_001a; + const CLOUD_VARIANT_MASK: u32 = 0xffff_0fff; + const ONEDRIVE_TAG: u32 = 0x8000_0021; + + if tag == 0 { + WindowsReparseKind::Ordinary + } else if tag & NAME_SURROGATE != 0 { + WindowsReparseKind::Redirection + } else if tag & CLOUD_VARIANT_MASK == CLOUD_TAG || tag == ONEDRIVE_TAG { + WindowsReparseKind::CloudPlaceholder + } else { + WindowsReparseKind::Unsupported + } +} + +#[cfg(any(windows, test))] +fn validate_windows_component(name: &str) -> Result<(), IndexError> { + super::directory_index::validate_directory_path(name)?; + if name.contains(['/', '\\', ':', '*', '?', '"', '<', '>', '|']) || name.ends_with(['.', ' ']) || name.chars().any(char::is_control) { + return Err(IndexError::Invalid( + "Directory name cannot round-trip through the Windows path API. Source coverage is incomplete.", + )); + } + Ok(()) +} + +#[cfg(any(windows, test))] +fn windows_entry_name(native: &[u16]) -> Result { + let name = String::from_utf16(native).map_err(|_| TraversalIssue::UnsupportedEncoding)?; + validate_windows_component(&name).map_err(|_| TraversalIssue::UnsupportedEncoding)?; + Ok(name) +} + +#[cfg(any(windows, test))] +fn windows_record_name_range( + remaining: usize, + next_offset: usize, + name_offset: usize, + name_bytes: usize, +) -> Result, IndexError> { + let record_bytes = if next_offset == 0 { remaining } else { next_offset }; + if name_bytes == 0 + || !name_bytes.is_multiple_of(size_of::()) + || record_bytes > remaining + || record_bytes < name_offset + || name_bytes > record_bytes - name_offset + || (next_offset != 0 && !record_bytes.is_multiple_of(size_of::())) + { + return Err(IndexError::Invalid( + "Directory enumeration returned an invalid native name record. Retry verification.", + )); + } + Ok(name_offset..name_offset + name_bytes) +} + +#[cfg(test)] +mod windows_policy_tests { + use super::*; + + #[test] + fn traversal_bounds_preserve_exact_depth_and_utf8_byte_limits_before_native_io() { + let depth = super::super::directory_manager::limit("max_source_depth").unwrap(); + let bytes = super::super::directory_manager::limit("max_source_path_bytes").unwrap(); + let allowed_depth = std::iter::repeat_n("entry", depth).collect::>().join("/"); + let allowed_bytes = "\u{e9}".repeat(bytes / 2); + for allowed in [&allowed_depth, &allowed_bytes] { + assert!(validate_traversal_path(Path::new(allowed)).is_ok()); + } + let temporary = tempfile::tempdir().unwrap(); + for refused in [format!("{allowed_depth}/child"), format!("{allowed_bytes}\u{e9}")] { + assert!(validate_traversal_path(Path::new(&refused)).is_err()); + assert!(matches!( + super::NativeRoot::open(&temporary.path().join("missing").join(refused)), + Err(IndexError::Invalid(_)) + )); + } + } + + #[test] + fn windows_native_names_preserve_exact_spelling_and_reject_lossy_or_ambiguous_names() { + for name in [ + ".hidden", + "\u{e9}", + "e\u{301}", + "MixedCASE", + "emoji-\u{1f4c1}", + "percent%underscore_", + ] { + let native: Vec<_> = name.encode_utf16().collect(); + assert_eq!(windows_entry_name(&native).unwrap(), name); + } + for native in [vec![0xd800], vec![0xdc00], vec![0xd800, b'a' as u16]] { + assert_eq!(windows_entry_name(&native), Err(TraversalIssue::UnsupportedEncoding)); + } + for name in [ + "", + ".", + "..", + "a/b", + "a\\b", + "stream:alternate", + "wild*card", + "name?", + "tail.", + "tail ", + "nul\0name", + "control\u{1}name", + ] { + let native: Vec<_> = name.encode_utf16().collect(); + assert_eq!( + windows_entry_name(&native), + Err(TraversalIssue::UnsupportedEncoding), + "Unexpected valid name: {name:?}" + ); + } + } + + #[test] + fn windows_reparse_tags_keep_redirections_cloud_and_unknown_types_distinct() { + assert_eq!(windows_reparse_kind(0), WindowsReparseKind::Ordinary); + for tag in [0xa000_0003, 0xa000_000c, 0xa000_001d] { + assert_eq!(windows_reparse_kind(tag), WindowsReparseKind::Redirection); + } + for variant in 0..=15 { + assert_eq!( + windows_reparse_kind(0x9000_001a | (variant << 12)), + WindowsReparseKind::CloudPlaceholder + ); + } + assert_eq!(windows_reparse_kind(0x8000_0021), WindowsReparseKind::CloudPlaceholder); + for tag in [0x8000_001b, 0x8000_0023, 0x8000_ffff, 1] { + assert_eq!(windows_reparse_kind(tag), WindowsReparseKind::Unsupported); + } + } + + #[test] + fn windows_directory_record_bounds_reject_truncation_overflow_and_misalignment() { + assert_eq!(windows_record_name_range(128, 112, 88, 8).unwrap(), 88..96); + assert_eq!(windows_record_name_range(128, 0, 88, 40).unwrap(), 88..128); + for (remaining, next, name_offset, name_bytes) in [ + (128, 0, 88, 0), + (128, 0, 88, 3), + (128, 136, 88, 2), + (128, 80, 88, 2), + (128, 112, 88, 26), + (128, 110, 88, 2), + (128, 0, usize::MAX, 2), + (128, 0, 88, usize::MAX - 1), + ] { + assert!(windows_record_name_range(remaining, next, name_offset, name_bytes).is_err()); + } + } +} + +#[cfg(target_os = "linux")] +mod platform { + use super::*; + use std::ffi::{CStr, CString}; + use std::fs::File; + use std::io; + use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd}; + use std::os::unix::ffi::OsStrExt; + use std::os::unix::fs::MetadataExt; + use std::path::{Component, PathBuf}; + use std::ptr::NonNull; + + const DIRECTORY_FLAGS: libc::c_int = libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC; + const EMPTY_NAME: &[u8] = b"\0"; + const CURRENT_NAME: &[u8] = b".\0"; + const ROOT_NAME: &[u8] = b"/\0"; + const DEVICE_ROOT_NAME: &[u8] = b"/dev\0"; + const CONFIGFS_MAGIC: libc::__fsword_t = 0x62656570; + const PSEUDO_FILESYSTEMS: &[libc::__fsword_t] = &[ + libc::PROC_SUPER_MAGIC, + libc::SYSFS_MAGIC, + libc::DEBUGFS_MAGIC, + libc::SECURITYFS_MAGIC, + libc::CGROUP_SUPER_MAGIC, + libc::CGROUP2_SUPER_MAGIC, + libc::DEVPTS_SUPER_MAGIC, + libc::TRACEFS_MAGIC, + CONFIGFS_MAGIC, + libc::BPF_FS_MAGIC, + libc::NSFS_MAGIC, + libc::SELINUX_MAGIC, + ]; + + pub(super) fn is_pseudo_filesystem(kind: libc::__fsword_t) -> bool { + PSEUDO_FILESYSTEMS.contains(&kind) + } + + /// Classify the held filesystem so root aliases cannot bypass kernel-tree exclusions. + fn validate_filesystem(file: &File, source: NativeIdentity) -> Result<(), IndexError> { + let mut filesystem = std::mem::MaybeUninit::::zeroed(); + if unsafe { libc::fstatfs(file.as_raw_fd(), filesystem.as_mut_ptr()) } != 0 { + return Err(io::Error::last_os_error().into()); + } + let filesystem = unsafe { filesystem.assume_init() }; + if is_pseudo_filesystem(filesystem.f_type) { + return Err(IndexError::Invalid( + "Kernel pseudo-filesystems cannot be indexed. Select a regular data directory or use recent directories.", + )); + } + let device_name = CStr::from_bytes_with_nul(DEVICE_ROOT_NAME).map_err(|_| IndexError::Invalid("Invalid device root name."))?; + match open_at(libc::AT_FDCWD, device_name) { + Ok(device_root) if device_root.metadata()?.dev() == source.device => { + return Err(IndexError::Invalid( + "Kernel device directories cannot be indexed. Select a regular data directory or use recent directories.", + )); + } + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + Ok(()) + } + + fn io_issue(error: &io::Error) -> TraversalIssue { + match error.kind() { + io::ErrorKind::PermissionDenied => TraversalIssue::AccessDenied, + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory => TraversalIssue::UnsafeTraversal, + _ => TraversalIssue::ScanFailed, + } + } + + /// The returned file owns the successful descriptor. Every component is opened without following links. + fn open_at(parent: libc::c_int, name: &CStr) -> io::Result { + let descriptor = unsafe { libc::openat(parent, name.as_ptr(), DIRECTORY_FLAGS) }; + if descriptor < 0 { + return Err(io::Error::last_os_error()); + } + Ok(unsafe { File::from_raw_fd(descriptor) }) + } + + /// statx reads the already-owned descriptor, not a path that can redirect the request. + fn identity(file: &File) -> Result { + let metadata = file.metadata()?; + let mut extended = std::mem::MaybeUninit::::zeroed(); + let result = unsafe { + libc::statx( + file.as_raw_fd(), + EMPTY_NAME.as_ptr().cast(), + libc::AT_EMPTY_PATH, + libc::STATX_MNT_ID, + extended.as_mut_ptr(), + ) + }; + let extended = unsafe { extended.assume_init() }; + let mount = if result == 0 && extended.stx_mask & libc::STATX_MNT_ID != 0 { + extended.stx_mnt_id + } else { + descriptor_mount_id( + &std::fs::read_to_string(format!("/proc/self/fdinfo/{}", file.as_raw_fd())) + .map_err(|_| IndexError::Invalid(MOUNT_BOUNDARY_CAPABILITY_ERROR))?, + )? + }; + Ok(NativeIdentity { + device: metadata.dev(), + inode: metadata.ino(), + mount, + }) + } + + fn descriptor_mount_id(information: &str) -> Result { + information + .lines() + .find_map(|line| line.strip_prefix("mnt_id:").and_then(|value| value.trim().parse().ok())) + .ok_or(IndexError::Invalid(MOUNT_BOUNDARY_CAPABILITY_ERROR)) + } + + #[test] + fn descriptor_mount_fallback_requires_the_kernel_mount_id() { + assert_eq!(descriptor_mount_id("pos:\t0\nmnt_id:\t42\nino:\t1\n").unwrap(), 42); + assert!(descriptor_mount_id("ino:\t42\n").is_err()); + assert!(descriptor_mount_id("mnt_id:\tinvalid\n").is_err()); + } + + pub struct NativeRoot { + path: PathBuf, + file: File, + pub identity: NativeIdentity, + } + + impl NativeRoot { + pub fn open(path: &Path) -> Result { + if !path.is_absolute() { + return Err(IndexError::Invalid("Directory source root must be an absolute directory.")); + } + super::validate_traversal_path(path)?; + let mut file = open_at( + libc::AT_FDCWD, + CStr::from_bytes_with_nul(ROOT_NAME).map_err(|_| IndexError::Invalid("Invalid root name."))?, + )?; + for component in path.components() { + match component { + Component::RootDir => {} + Component::Normal(name) => { + let name = CString::new(name.as_bytes()) + .map_err(|_| IndexError::Invalid("Directory source root contains an unsupported name."))?; + file = open_at(file.as_raw_fd(), &name)?; + } + _ => { + return Err(IndexError::Invalid( + "Directory source root must not contain dot or parent components.", + )) + } + } + } + let identity = identity(&file)?; + validate_filesystem(&file, identity)?; + Ok(Self { + path: path.to_owned(), + file, + identity, + }) + } + + pub fn verify(&self) -> Result<(), IndexError> { + if Self::open(&self.path)?.identity != self.identity { + return Err(io::Error::new( + io::ErrorKind::NotConnected, + "Directory root binding changed. Background verification will bind the root again.", + ) + .into()); + } + Ok(()) + } + + fn parent(&self, relative: &str) -> Result { + super::validate_traversal_path(&self.path.join(relative))?; + self.verify()?; + let mut file = open_at( + self.file.as_raw_fd(), + CStr::from_bytes_with_nul(CURRENT_NAME).map_err(|_| IndexError::Invalid("Invalid current directory name."))?, + )?; + if !relative.is_empty() { + super::super::directory_index::validate_directory_path(relative)?; + for component in relative.split('/') { + let name = CString::new(component).map_err(|_| IndexError::Invalid("Directory name contains NUL."))?; + file = open_at(file.as_raw_fd(), &name)?; + let current = identity(&file)?; + if current.device != self.identity.device || current.mount != self.identity.mount { + return Err(IndexError::Invalid( + "Directory traversal would cross a mount. Index the mounted drive separately.", + )); + } + } + } + Ok(file) + } + + pub fn verify_parent(&self, parent: &str, expected: NativeIdentity) -> Result<(), IndexError> { + if identity(&self.parent(parent)?)? != expected { + return Err(IndexError::Invalid( + "Directory parent changed during scanning. Retry source verification.", + )); + } + Ok(()) + } + + pub fn parent_identity(&self, parent: &str) -> Result { + identity(&self.parent(parent)?) + } + + /// Check one exact child through the held parent; errors are not evidence of absence. + #[cfg(test)] + pub fn has_directory_child(&self, parent: &str, expected: NativeIdentity, name: &str) -> Result { + self.has_directory_child_with_check(parent, expected, name, || Ok(())) + } + + pub fn has_directory_child_with_check( + &self, + parent: &str, + expected: NativeIdentity, + name: &str, + mut check: impl FnMut() -> Result<(), IndexError>, + ) -> Result { + check()?; + super::super::directory_index::validate_directory_path(name)?; + if name.contains('/') { + return Err(IndexError::Invalid("Directory child check requires one native name.")); + } + super::validate_traversal_path(&self.path.join(parent).join(name))?; + let file = self.parent(parent)?; + if identity(&file)? != expected { + return Err(IndexError::Invalid( + "Directory parent changed during reconciliation. Retry source verification.", + )); + } + let name = CString::new(name).map_err(|_| IndexError::Invalid("Directory child name contains NUL."))?; + let outcome = (|| { + check()?; + let mut metadata = std::mem::MaybeUninit::::zeroed(); + if unsafe { libc::fstatat(file.as_raw_fd(), name.as_ptr(), metadata.as_mut_ptr(), libc::AT_SYMLINK_NOFOLLOW) } != 0 { + let error = io::Error::last_os_error(); + return if error.kind() == io::ErrorKind::NotFound { + Ok(false) + } else { + Err(error.into()) + }; + } + let kind = unsafe { metadata.assume_init() }.st_mode & libc::S_IFMT; + if kind == libc::S_IFLNK { + return Err(IndexError::Invalid( + "Directory child is a redirection. Retry source verification under the traversal policy.", + )); + } + if kind != libc::S_IFDIR { + return Ok(false); + } + check()?; + let child = match open_at(file.as_raw_fd(), &name) { + Ok(child) => child, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error.into()), + }; + let current = identity(&child)?; + if current.device != self.identity.device || current.mount != self.identity.mount { + return Err(IndexError::Invalid( + "Directory child is on a different mount. Retry verification without crossing filesystems.", + )); + } + Ok(true) + })(); + let found = outcome?; + check()?; + self.verify_parent(parent, expected)?; + Ok(found) + } + + /// fdopendir owns its descriptor; copied names remain valid after the next readdir call. + pub fn children( + &self, + parent: &str, + mut receive: impl FnMut(Result, TraversalIssue>) -> Result<(), IndexError>, + ) -> Result<(), IndexError> { + let file = self.parent(parent)?; + let parent_identity = identity(&file)?; + let descriptor = file.into_raw_fd(); + let pointer = unsafe { libc::fdopendir(descriptor) }; + let Some(pointer) = NonNull::new(pointer) else { + let error = io::Error::last_os_error(); + drop(unsafe { File::from_raw_fd(descriptor) }); + return Err(error.into()); + }; + let directory = NativeDirectory(pointer); + loop { + receive(Ok(None))?; + unsafe { + *libc::__errno_location() = 0; + } + let entry = unsafe { libc::readdir(directory.0.as_ptr()) }; + if entry.is_null() { + let error = io::Error::last_os_error(); + if error.raw_os_error() != Some(0) { + return Err(error.into()); + } + return Ok(()); + } + let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }; + if matches!(name.to_bytes(), b"." | b"..") { + continue; + } + let Ok(text) = name.to_str() else { + receive(Err(TraversalIssue::UnsupportedEncoding))?; + continue; + }; + let mut metadata = std::mem::MaybeUninit::::zeroed(); + let result = unsafe { libc::fstatat(descriptor, name.as_ptr(), metadata.as_mut_ptr(), libc::AT_SYMLINK_NOFOLLOW) }; + if result != 0 { + let error = io::Error::last_os_error(); + if matches!(error.kind(), io::ErrorKind::NotFound | io::ErrorKind::NotADirectory) { + if self.has_directory_child_with_check(parent, parent_identity, text, || receive(Ok(None)))? { + return Err(IndexError::Interrupted); + } + } else { + receive(Err(io_issue(&error)))?; + } + continue; + } + let metadata = unsafe { metadata.assume_init() }; + let kind = metadata.st_mode & libc::S_IFMT; + if kind == libc::S_IFLNK { + receive(Err(TraversalIssue::UnsafeTraversal))?; + continue; + } + if kind != libc::S_IFDIR { + continue; + } + if super::validate_traversal_path(&self.path.join(parent).join(text)).is_err() { + receive(Err(TraversalIssue::UnsafeTraversal))?; + continue; + } + match open_at(descriptor, name).and_then(|child| identity(&child).map_err(io::Error::other)) { + Ok(child) if child.device == self.identity.device && child.mount == self.identity.mount => { + receive(Ok(Some((text.to_owned(), true))))? + } + Ok(_) => { + receive(Ok(Some((text.to_owned(), false))))?; + receive(Err(TraversalIssue::UnsafeTraversal))?; + } + Err(error) => { + if matches!(error.kind(), io::ErrorKind::NotFound | io::ErrorKind::NotADirectory) { + if self.has_directory_child_with_check(parent, parent_identity, text, || receive(Ok(None)))? { + return Err(IndexError::Interrupted); + } + continue; + } + receive(Ok(Some((text.to_owned(), false))))?; + receive(Err(io_issue(&error)))?; + } + } + } + } + } + + struct NativeDirectory(NonNull); + + impl Drop for NativeDirectory { + fn drop(&mut self) { + unsafe { + libc::closedir(self.0.as_ptr()); + } + } + } +} + +#[cfg(target_os = "linux")] +pub use platform::NativeRoot; + +#[cfg(windows)] +#[path = "directory_traversal_windows.rs"] +mod windows_platform; + +#[cfg(windows)] +pub use windows_platform::NativeRoot; + +#[cfg(not(any(target_os = "linux", windows)))] +pub struct NativeRoot { + pub identity: NativeIdentity, +} + +#[cfg(not(any(target_os = "linux", windows)))] +impl NativeRoot { + pub fn open(_: &Path) -> Result { + Err(IndexError::Invalid("Safe directory indexing is not available on this platform. Use recent directories until a verified traversal adapter is available.")) + } + pub fn verify(&self) -> Result<(), IndexError> { + Self::open(Path::new("")).map(|_| ()) + } + pub fn parent_identity(&self, _: &str) -> Result { + self.verify().map(|_| self.identity) + } + pub fn verify_parent(&self, _: &str, _: NativeIdentity) -> Result<(), IndexError> { + self.verify() + } + pub fn has_directory_child(&self, _: &str, _: NativeIdentity, _: &str) -> Result { + Err(IndexError::Invalid( + "Safe directory reconciliation is unavailable on this platform. Use recent directories.", + )) + } + pub fn has_directory_child_with_check( + &self, + parent: &str, + expected: NativeIdentity, + name: &str, + mut check: impl FnMut() -> Result<(), IndexError>, + ) -> Result { + check()?; + self.has_directory_child(parent, expected, name) + } + pub fn children( + &self, + _: &str, + _: impl FnMut(Result, TraversalIssue>) -> Result<(), IndexError>, + ) -> Result<(), IndexError> { + self.verify() + } +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + use std::os::unix::ffi::OsStringExt; + use std::os::unix::fs::symlink; + + #[test] + fn kernel_and_device_roots_are_rejected() { + for path in ["/proc", "/sys", "/dev"] { + assert!(NativeRoot::open(Path::new(path)).is_err(), "Kernel root was accepted: {path}"); + } + for kind in [ + libc::PROC_SUPER_MAGIC, + libc::SYSFS_MAGIC, + libc::CGROUP2_SUPER_MAGIC, + libc::DEVPTS_SUPER_MAGIC, + ] { + assert!(platform::is_pseudo_filesystem(kind)); + } + assert!(!platform::is_pseudo_filesystem(libc::TMPFS_MAGIC)); + assert!(!platform::is_pseudo_filesystem(libc::EXT4_SUPER_MAGIC)); + let root = NativeRoot::open(Path::new("/")).unwrap(); + assert!(root.parent_identity("proc").is_err()); + assert!(root.parent_identity("dev").is_err()); + } + + #[test] + fn root_links_and_component_links_are_rejected() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("root"); + std::fs::create_dir(&root).unwrap(); + let link = temporary.path().join("link"); + symlink(&root, &link).unwrap(); + assert!(NativeRoot::open(&link).is_err()); + assert!(NativeRoot::open(&link.join("child")).is_err()); + } + + #[test] + fn traversal_never_follows_links_or_uses_lossy_names() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("root"); + std::fs::create_dir(&root).unwrap(); + std::fs::create_dir(root.join("visible")).unwrap(); + std::fs::create_dir(root.join(".hidden")).unwrap(); + std::fs::create_dir(root.join(std::ffi::OsString::from_vec(vec![0xff]))).unwrap(); + symlink(temporary.path(), root.join("outside")).unwrap(); + let source = NativeRoot::open(&root).unwrap(); + let mut rows = Vec::new(); + let mut issues = Vec::new(); + source + .children("", |entry| { + match entry { + Ok(Some(row)) => rows.push(row), + Ok(None) => {} + Err(issue) => issues.push(issue), + }; + Ok(()) + }) + .unwrap(); + rows.sort(); + assert_eq!(rows, vec![(".hidden".to_owned(), true), ("visible".to_owned(), true)]); + assert!(issues.contains(&TraversalIssue::UnsafeTraversal)); + assert!(issues.contains(&TraversalIssue::UnsupportedEncoding)); + } + + #[test] + fn child_checks_use_exact_names_and_do_not_treat_redirections_or_replacement_as_absence() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("root"); + std::fs::create_dir_all(root.join("parent/é")).unwrap(); + std::fs::write(root.join("parent/file"), b"fixture").unwrap(); + symlink(temporary.path(), root.join("parent/link")).unwrap(); + let source = NativeRoot::open(&root).unwrap(); + let parent = source.parent_identity("parent").unwrap(); + assert!(source.has_directory_child("parent", parent, "é").unwrap()); + assert!(!source.has_directory_child("parent", parent, "missing").unwrap()); + assert!(!source.has_directory_child("parent", parent, "file").unwrap()); + assert!(source.has_directory_child("parent", parent, "link").is_err()); + assert!(source.has_directory_child("parent", parent, "nested/name").is_err()); + std::fs::rename(root.join("parent"), root.join("old-parent")).unwrap(); + std::fs::create_dir(root.join("parent")).unwrap(); + assert!(source.has_directory_child("parent", parent, "missing").is_err()); + } + + #[test] + fn child_lookup_obeys_control_checks_before_and_after_native_work() { + let temporary = tempfile::tempdir().unwrap(); + std::fs::create_dir(temporary.path().join("child")).unwrap(); + let root = NativeRoot::open(temporary.path()).unwrap(); + for stop_at in 1..=4 { + let mut checks = 0; + let result = root.has_directory_child_with_check("", root.identity, "child", || { + checks += 1; + if checks == stop_at { + Err(IndexError::Interrupted) + } else { + Ok(()) + } + }); + assert!(matches!(result, Err(IndexError::Interrupted))); + assert_eq!(checks, stop_at); + root.verify().unwrap(); + } + assert!(root.has_directory_child("", root.identity, "child").unwrap()); + } + + #[test] + fn traversal_depth_refusal_preserves_readable_branches() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("root"); + std::fs::create_dir_all(path.join("readable")).unwrap(); + let maximum_depth = super::super::directory_manager::limit("max_source_depth").unwrap(); + let root_depth = path + .components() + .filter(|component| matches!(component, std::path::Component::Normal(_))) + .count(); + let parent = std::iter::repeat_n("d", maximum_depth - root_depth).collect::>().join("/"); + std::fs::create_dir_all(path.join(&parent).join("excluded")).unwrap(); + let root = NativeRoot::open(&path).unwrap(); + let mut entries = Vec::new(); + let mut issues = Vec::new(); + root.children(&parent, |entry| { + match entry { + Ok(Some(entry)) => entries.push(entry), + Err(issue) => issues.push(issue), + Ok(None) => {} + } + Ok(()) + }) + .unwrap(); + assert!(entries.is_empty()); + assert_eq!(issues, [TraversalIssue::UnsafeTraversal]); + root.children("", |entry| { + if let Ok(Some(entry)) = entry { + entries.push(entry); + } + Ok(()) + }) + .unwrap(); + assert!(entries.iter().any(|(name, descend)| name == "readable" && *descend)); + } + + #[test] + fn many_regular_files_keep_enumeration_cancellable_without_a_directory_cutoff() { + use super::super::directory_index::ROW_BATCH_SIZE; + + const FILE_COUNT: usize = ROW_BATCH_SIZE * 2 + 1; + + let temporary = tempfile::tempdir().unwrap(); + for number in 0..FILE_COUNT { + std::fs::write(temporary.path().join(format!("file-{number}")), b"").unwrap(); + } + std::fs::create_dir(temporary.path().join("directory")).unwrap(); + let root = NativeRoot::open(temporary.path()).unwrap(); + let mut checks = 0; + let outcome = root.children("", |_| { + checks += 1; + if checks == ROW_BATCH_SIZE { + Err(IndexError::Interrupted) + } else { + Ok(()) + } + }); + assert!(matches!(outcome, Err(IndexError::Interrupted))); + assert_eq!(checks, ROW_BATCH_SIZE); + root.verify().unwrap(); + let mut rows = Vec::new(); + checks = 0; + root.children("", |entry| { + checks += 1; + if let Some(row) = entry.unwrap() { + rows.push(row); + } + Ok(()) + }) + .unwrap(); + assert!(checks >= FILE_COUNT); + assert_eq!(rows, vec![("directory".to_owned(), true)]); + } + + #[test] + fn parent_and_root_replacement_invalidate_observations() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("root"); + std::fs::create_dir(&root).unwrap(); + std::fs::create_dir(root.join("parent")).unwrap(); + let source = NativeRoot::open(&root).unwrap(); + let observed = source.parent_identity("parent").unwrap(); + std::fs::rename(root.join("parent"), root.join("old")).unwrap(); + std::fs::create_dir(root.join("parent")).unwrap(); + assert!(source.verify_parent("parent", observed).is_err()); + std::fs::rename(&root, temporary.path().join("old-root")).unwrap(); + std::fs::create_dir(&root).unwrap(); + assert!(source.verify().is_err()); + } +} diff --git a/companion/src-tauri/src/server/directory_traversal_windows.rs b/companion/src-tauri/src/server/directory_traversal_windows.rs new file mode 100644 index 000000000..e6624e164 --- /dev/null +++ b/companion/src-tauri/src/server/directory_traversal_windows.rs @@ -0,0 +1,674 @@ +//! Windows traversal with retained ancestor handles and exact native identities. + +use super::{ + validate_windows_component as validate_name, windows_entry_name, windows_record_name_range, windows_reparse_kind, IndexError, + NativeIdentity, TraversalIssue, WindowsReparseKind, +}; +use std::fs::{File, OpenOptions}; +use std::io; +use std::mem::{offset_of, size_of}; +use std::os::windows::fs::OpenOptionsExt; +use std::os::windows::io::AsRawHandle; +use std::path::{Component, Path, PathBuf, Prefix}; +use windows::Win32::Foundation::{ERROR_NO_MORE_FILES, HANDLE}; +use windows::Win32::Storage::FileSystem::{ + FileAttributeTagInfo, FileIdExtdDirectoryInfo, FileIdExtdDirectoryRestartInfo, FileIdInfo, GetFileInformationByHandleEx, + GetFinalPathNameByHandleW, FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT, FILE_ATTRIBUTE_TAG_INFO, FILE_FLAG_BACKUP_SEMANTICS, + FILE_FLAG_OPEN_REPARSE_POINT, FILE_ID_EXTD_DIR_INFO, FILE_ID_INFO, FILE_INFO_BY_HANDLE_CLASS, FILE_LIST_DIRECTORY, + FILE_READ_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, VOLUME_NAME_NT, +}; + +const DIRECTORY_BUFFER_BYTES: usize = 64 * 1024; +const DIRECTORY_BUFFER_WORDS: usize = DIRECTORY_BUFFER_BYTES / size_of::(); +const FILE_ID_HALF_BYTES: usize = size_of::(); +const MAX_NATIVE_PATH_UNITS: usize = 32 * 1024; + +fn query(file: &File, class: FILE_INFO_BY_HANDLE_CLASS, output: &mut T) -> Result<(), IndexError> { + unsafe { + GetFileInformationByHandleEx( + HANDLE(file.as_raw_handle()), + class, + std::ptr::from_mut(output).cast(), + size_of::() as u32, + ) + } + .map_err(|error| io::Error::from_raw_os_error(error.code().0 & 0xffff))?; + Ok(()) +} + +fn identity(file: &File) -> Result { + let mut information = FILE_ID_INFO::default(); + query(file, FileIdInfo, &mut information)?; + Ok(identity_information(&information)) +} + +fn identity_information(information: &FILE_ID_INFO) -> NativeIdentity { + let bytes = information.FileId.Identifier; + let mut low = [0; FILE_ID_HALF_BYTES]; + let mut high = [0; FILE_ID_HALF_BYTES]; + low.copy_from_slice(&bytes[..FILE_ID_HALF_BYTES]); + high.copy_from_slice(&bytes[FILE_ID_HALF_BYTES..]); + NativeIdentity { + device: information.VolumeSerialNumber, + inode: u64::from_le_bytes(low), + mount: u64::from_le_bytes(high), + } +} + +#[test] +fn zero_volume_serial_and_full_file_id_are_valid_native_values() { + let mut information = FILE_ID_INFO::default(); + assert_eq!(identity_information(&information).device, 0); + information.VolumeSerialNumber = u64::from(u32::MAX) + 1; + information.FileId.Identifier = [u8::MAX; size_of::()]; + let found = identity_information(&information); + assert_eq!(found.device, u64::from(u32::MAX) + 1); + assert_eq!(found.inode, u64::MAX); + assert_eq!(found.mount, u64::MAX); +} + +fn attributes(file: &File) -> Result { + let mut information = FILE_ATTRIBUTE_TAG_INFO::default(); + query(file, FileAttributeTagInfo, &mut information)?; + Ok(information) +} + +fn native_path(file: &File) -> Result { + let mut buffer = Vec::new(); + buffer + .try_reserve_exact(MAX_NATIVE_PATH_UNITS) + .map_err(|_| IndexError::Invalid("Directory identity buffer allocation failed. Close other applications and retry indexing."))?; + buffer.resize(MAX_NATIVE_PATH_UNITS, 0u16); + let length = unsafe { GetFinalPathNameByHandleW(HANDLE(file.as_raw_handle()), &mut buffer, VOLUME_NAME_NT) } as usize; + if length == 0 { + return Err(io::Error::last_os_error().into()); + } + if length >= buffer.len() { + return Err(IndexError::Invalid( + "Directory native identity path exceeds the supported size. Source coverage is incomplete.", + )); + } + let path = String::from_utf16(&buffer[..length]) + .map_err(|_| IndexError::Invalid("Directory native identity has unsupported encoding. Use a supported source."))?; + Ok(PathBuf::from(path)) +} + +fn open_directory(path: &Path) -> Result { + let file = OpenOptions::new() + .read(true) + .access_mode(FILE_LIST_DIRECTORY.0 | FILE_READ_ATTRIBUTES.0) + .share_mode(FILE_SHARE_READ.0 | FILE_SHARE_WRITE.0) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS.0 | FILE_FLAG_OPEN_REPARSE_POINT.0) + .open(path)?; + let information = attributes(&file)?; + if information.FileAttributes & FILE_ATTRIBUTE_DIRECTORY.0 == 0 + || information.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 + || windows_reparse_kind(information.ReparseTag) != WindowsReparseKind::Ordinary + { + return Err(IndexError::Invalid( + "Directory path is redirected or unavailable. Select a real directory on a supported volume.", + )); + } + Ok(file) +} + +fn root_path(path: &Path) -> Result { + if !path.is_absolute() { + return Err(IndexError::Invalid("Directory source root must be an absolute directory.")); + } + let mut components = path.components(); + let Some(Component::Prefix(prefix)) = components.next() else { + return Err(IndexError::Invalid("Directory source root has no supported Windows volume prefix.")); + }; + let mut result = match prefix.kind() { + Prefix::Disk(drive) | Prefix::VerbatimDisk(drive) => PathBuf::from(format!("\\\\?\\{}:\\", char::from(drive))), + Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => { + let server = server + .to_str() + .ok_or(IndexError::Invalid("Directory server name has unsupported native encoding."))?; + let share = share + .to_str() + .ok_or(IndexError::Invalid("Directory share name has unsupported native encoding."))?; + validate_name(server)?; + validate_name(share)?; + PathBuf::from(format!("\\\\?\\UNC\\{server}\\{share}\\")) + } + _ => { + return Err(IndexError::Invalid( + "Device namespace paths cannot be indexed. Select a drive or share root.", + )) + } + }; + for component in components { + match component { + Component::RootDir => {} + Component::Normal(name) => { + let name = name + .to_str() + .ok_or(IndexError::Invalid("Directory path has unsupported native encoding."))?; + validate_name(name)?; + result.push(name); + } + _ => { + return Err(IndexError::Invalid( + "Directory source root must not contain dot or parent components.", + )) + } + } + } + Ok(result) +} + +/// Compare handle-normalized paths, not input components that can contain DOS aliases. +fn verify_native_parent(parent: &Path, child: &Path) -> Result<(), IndexError> { + if child.parent() != Some(parent) { + return Err(IndexError::Invalid( + "Directory ancestry changed or redirected during open. Retry source verification.", + )); + } + Ok(()) +} + +/// No ancestor permits deletion or rename while a descendant is opened or read. +fn open_chain(path: &Path) -> Result, IndexError> { + let mut current = PathBuf::new(); + let mut files = Vec::new(); + let mut volume = None; + let mut binding: Option = None; + for component in path.components() { + current.push(component); + if matches!(component, Component::Prefix(_)) { + continue; + } + let file = open_directory(¤t)?; + let found = identity(&file)?; + let native = native_path(&file)?; + if let Some(parent) = binding.as_ref() { + verify_native_parent(parent, &native)?; + } + if volume.is_some_and(|expected| expected != found.device) { + return Err(IndexError::Invalid( + "Directory traversal would cross a volume. Index the mounted drive separately.", + )); + } + volume = Some(found.device); + binding = Some(native); + files.push(file); + } + if files.is_empty() { + return Err(IndexError::Invalid("Directory source root has no directory handle.")); + } + Ok(files) +} + +fn last(files: &[File]) -> Result<&File, IndexError> { + files + .last() + .ok_or(IndexError::Invalid("Directory source handle is unavailable. Reopen the drive.")) +} + +/// Each native record and UTF-16 name is checked before it is read from the bounded buffer. +fn visit( + file: &File, + mut receive: impl FnMut(Option<(&FILE_ID_EXTD_DIR_INFO, &[u16])>) -> Result, +) -> Result<(), IndexError> { + let mut buffer = Vec::new(); + buffer + .try_reserve_exact(DIRECTORY_BUFFER_WORDS) + .map_err(|_| IndexError::Invalid("Directory enumeration buffer allocation failed. Close other applications and retry indexing."))?; + buffer.resize(DIRECTORY_BUFFER_WORDS, 0u64); + let name_offset = offset_of!(FILE_ID_EXTD_DIR_INFO, FileName); + let mut class = FileIdExtdDirectoryRestartInfo; + loop { + if !receive(None)? { + return Ok(()); + } + buffer.fill(0); + let result = unsafe { + GetFileInformationByHandleEx( + HANDLE(file.as_raw_handle()), + class, + buffer.as_mut_ptr().cast(), + DIRECTORY_BUFFER_BYTES as u32, + ) + }; + if let Err(error) = result { + if error.code() == windows::core::HRESULT::from_win32(ERROR_NO_MORE_FILES.0) { + return Ok(()); + } + return Err(io::Error::from_raw_os_error(error.code().0 & 0xffff).into()); + } + class = FileIdExtdDirectoryInfo; + let mut offset = 0usize; + loop { + if !receive(None)? { + return Ok(()); + } + if DIRECTORY_BUFFER_BYTES - offset < size_of::() { + return Err(IndexError::Invalid( + "Directory enumeration returned an incomplete native record. Retry verification.", + )); + } + let record = unsafe { + buffer + .as_ptr() + .cast::() + .add(offset) + .cast::() + .read_unaligned() + }; + let name_bytes = record.FileNameLength as usize; + let remaining = DIRECTORY_BUFFER_BYTES - offset; + let range = windows_record_name_range(remaining, record.NextEntryOffset as usize, name_offset, name_bytes)?; + let name = unsafe { + std::slice::from_raw_parts( + buffer.as_ptr().cast::().add(offset + range.start).cast::(), + range.len() / size_of::(), + ) + }; + if !receive(Some((&record, name)))? { + return Ok(()); + } + if record.NextEntryOffset == 0 { + break; + } + offset += record.NextEntryOffset as usize; + } + } +} + +pub struct NativeRoot { + path: PathBuf, + binding: PathBuf, + files: Vec, + pub identity: NativeIdentity, +} + +impl NativeRoot { + pub fn open(path: &Path) -> Result { + super::validate_traversal_path(path)?; + let path = root_path(path)?; + let files = open_chain(&path)?; + let identity = identity(last(&files)?)?; + let binding = native_path(last(&files)?)?; + Ok(Self { + path, + binding, + files, + identity, + }) + } + + pub fn verify(&self) -> Result<(), IndexError> { + let current = open_chain(&self.path)?; + if identity(last(&self.files)?)? != self.identity + || identity(last(¤t)?)? != self.identity + || native_path(last(¤t)?)? != self.binding + || native_path(last(&self.files)?)? != self.binding + { + return Err(io::Error::new( + io::ErrorKind::NotConnected, + "Directory root binding changed. Background verification will bind the root again.", + ) + .into()); + } + Ok(()) + } + + fn parent(&self, relative: &str) -> Result, IndexError> { + self.verify()?; + let mut path = self.path.clone(); + if !relative.is_empty() { + super::super::directory_index::validate_directory_path(relative)?; + for name in relative.split('/') { + validate_name(name)?; + path.push(name); + } + } + super::validate_traversal_path(&path)?; + let files = open_chain(&path)?; + let expected = if relative.is_empty() { + self.binding.clone() + } else { + self.binding.join(relative.replace('/', "\\")) + }; + if identity(last(&files)?)?.device != self.identity.device || native_path(last(&files)?)? != expected { + return Err(IndexError::Invalid( + "Directory parent belongs to a different volume. Retry source verification.", + )); + } + Ok(files) + } + + pub fn parent_identity(&self, parent: &str) -> Result { + identity(last(&self.parent(parent)?)?) + } + + pub fn verify_parent(&self, parent: &str, expected: NativeIdentity) -> Result<(), IndexError> { + if self.parent_identity(parent)? != expected { + return Err(IndexError::Invalid( + "Directory parent changed during scanning. Retry source verification.", + )); + } + Ok(()) + } + + #[cfg(test)] + pub fn has_directory_child(&self, parent: &str, expected: NativeIdentity, name: &str) -> Result { + self.has_directory_child_with_check(parent, expected, name, || Ok(())) + } + + pub fn has_directory_child_with_check( + &self, + parent: &str, + expected: NativeIdentity, + name: &str, + mut check: impl FnMut() -> Result<(), IndexError>, + ) -> Result { + check()?; + validate_name(name)?; + super::validate_traversal_path(&self.path.join(parent.replace('/', "\\")).join(name))?; + let files = self.parent(parent)?; + let file = last(&files)?; + if identity(file)? != expected { + return Err(IndexError::Invalid( + "Directory parent changed during reconciliation. Retry source verification.", + )); + } + let mut found = false; + visit(file, |entry| { + check()?; + self.verify_parent(parent, expected)?; + let Some((record, native_name)) = entry else { + return Ok(true); + }; + let text = + String::from_utf16(native_name).map_err(|_| IndexError::Invalid("Directory entry has unsupported native encoding."))?; + if text != name { + return Ok(true); + } + if record.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 + || windows_reparse_kind(record.ReparsePointTag) != WindowsReparseKind::Ordinary + { + return Err(IndexError::Invalid( + "Directory child is redirected or unverified. Retry full source verification.", + )); + } + found = record.FileAttributes & FILE_ATTRIBUTE_DIRECTORY.0 != 0; + Ok(false) + })?; + check()?; + self.verify_parent(parent, expected)?; + Ok(found) + } + + pub fn children( + &self, + parent: &str, + mut receive: impl FnMut(Result, TraversalIssue>) -> Result<(), IndexError>, + ) -> Result<(), IndexError> { + let files = self.parent(parent)?; + let file = last(&files)?; + let expected = identity(file)?; + visit(file, |entry| { + let Some((record, native_name)) = entry else { + receive(Ok(None))?; + return Ok(true); + }; + if native_name == [b'.' as u16] || native_name == [b'.' as u16, b'.' as u16] { + return Ok(true); + } + let name = match windows_entry_name(native_name) { + Ok(name) => name, + Err(issue) => { + receive(Err(issue))?; + return Ok(true); + } + }; + if record.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 + || windows_reparse_kind(record.ReparsePointTag) != WindowsReparseKind::Ordinary + { + receive(Err(TraversalIssue::UnsafeTraversal))?; + return Ok(true); + } + if record.FileAttributes & FILE_ATTRIBUTE_DIRECTORY.0 == 0 { + return Ok(true); + } + let path = self.path.join(parent.replace('/', "\\")).join(&name); + if super::validate_traversal_path(&path).is_err() { + receive(Err(TraversalIssue::UnsafeTraversal))?; + return Ok(true); + } + let event = match open_directory(&path).and_then(|child| { + if native_path(&child)? != native_path(file)?.join(&name) { + return Err(IndexError::Invalid( + "Directory child was redirected outside its observed parent. Retry verification.", + )); + } + identity(&child) + }) { + Ok(child) + if child.device == self.identity.device + && child.inode.to_le_bytes() == record.FileId.Identifier[..FILE_ID_HALF_BYTES] + && child.mount.to_le_bytes() == record.FileId.Identifier[FILE_ID_HALF_BYTES..] => + { + Ok(Some((name, true))) + } + Ok(_) => Err(TraversalIssue::UnsafeTraversal), + Err(IndexError::Io(error)) if error.kind() == io::ErrorKind::PermissionDenied => Err(TraversalIssue::AccessDenied), + Err(_) => Err(TraversalIssue::UnsafeTraversal), + }; + self.verify_parent(parent, expected)?; + receive(event)?; + Ok(true) + })?; + self.verify_parent(parent, expected) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::windows::ffi::{OsStrExt, OsStringExt}; + use windows::core::PCWSTR; + use windows::Win32::Storage::FileSystem::GetShortPathNameW; + + #[test] + fn normalized_ancestry_accepts_aliases_but_rejects_different_parents() { + let parent = Path::new(r"\Device\HarddiskVolume1\Users"); + let normalized = parent.join("runneradmin"); + assert_ne!(parent.join("RUNNER~1"), normalized); + verify_native_parent(parent, &normalized).unwrap(); + for redirected in [ + r"\Device\HarddiskVolume1\UsersElse\runneradmin", + r"\Device\HarddiskVolume1\Users\runneradmin\AppData", + r"\Device\HarddiskVolume2\Users\runneradmin", + r"\Device\HarddiskVolume1\users\runneradmin", + r"\Device\HarddiskVolume1\Users", + ] { + assert!( + verify_native_parent(parent, Path::new(redirected)).is_err(), + "Unexpected ancestry: {redirected}" + ); + } + } + + #[test] + fn short_root_paths_preserve_handle_identity_verification_and_enumeration() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("Mixed Case Source Directory"); + std::fs::create_dir_all(path.join("parent/child")).unwrap(); + let native: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect(); + let mut buffer = vec![0u16; MAX_NATIVE_PATH_UNITS]; + let length = unsafe { GetShortPathNameW(PCWSTR(native.as_ptr()), Some(&mut buffer)) } as usize; + assert!( + length > 0 && length < buffer.len(), + "Short path query failed: {}", + io::Error::last_os_error() + ); + let alias = PathBuf::from(std::ffi::OsString::from_wide(&buffer[..length])); + let root = NativeRoot::open(&path).unwrap(); + let short_root = NativeRoot::open(&alias).unwrap(); + assert_eq!(short_root.identity, root.identity); + assert_eq!(short_root.binding, root.binding); + short_root.verify().unwrap(); + let parent = short_root.parent_identity("parent").unwrap(); + assert!(short_root.has_directory_child("parent", parent, "child").unwrap()); + let mut rows = Vec::new(); + short_root + .children("parent", |event| { + match event { + Ok(Some(row)) => rows.push(row), + Ok(None) => {} + Err(issue) => panic!("Unexpected traversal issue: {issue:?}"), + } + Ok(()) + }) + .unwrap(); + assert_eq!(rows, vec![("child".to_owned(), true)]); + } + + #[test] + fn junction_ancestors_and_children_are_rejected() { + let temporary = tempfile::tempdir().unwrap(); + let target = temporary.path().join("target"); + let junction = temporary.path().join("redirected"); + std::fs::create_dir_all(target.join("child")).unwrap(); + let result = std::process::Command::new("cmd") + .args(["/d", "/c", "mklink", "/J"]) + .arg(&junction) + .arg(&target) + .output() + .unwrap(); + assert!(result.status.success(), "Junction creation failed: {result:?}"); + for path in [&junction, &junction.join("child")] { + assert!(matches!(NativeRoot::open(path), Err(IndexError::Invalid(_)))); + } + let root = NativeRoot::open(temporary.path()).unwrap(); + let mut issues = Vec::new(); + root.children("", |event| { + if let Err(issue) = event { + issues.push(issue); + } + Ok(()) + }) + .unwrap(); + assert_eq!(issues, vec![TraversalIssue::UnsafeTraversal]); + assert!(root.has_directory_child("", root.identity, "redirected").is_err()); + } + + #[test] + fn native_handles_preserve_exact_names_and_exclude_regular_files() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("source"); + std::fs::create_dir_all(path.join("parent/é")).unwrap(); + std::fs::create_dir(path.join("parent/.hidden")).unwrap(); + std::fs::write(path.join("parent/file.lnk"), b"not a directory").unwrap(); + let root = NativeRoot::open(&path).unwrap(); + let parent = root.parent_identity("parent").unwrap(); + let mut rows = Vec::new(); + root.children("parent", |event| { + match event { + Ok(Some(row)) => rows.push(row), + Ok(None) => {} + Err(issue) => panic!("Unexpected traversal issue: {issue:?}"), + } + Ok(()) + }) + .unwrap(); + rows.sort(); + assert_eq!(rows, vec![(".hidden".to_owned(), true), ("é".to_owned(), true)]); + assert!(root.has_directory_child("parent", parent, "é").unwrap()); + assert!(!root.has_directory_child("parent", parent, "É").unwrap()); + assert!(!root.has_directory_child("parent", parent, "missing").unwrap()); + assert!(!root.has_directory_child("parent", parent, "file.lnk").unwrap()); + assert!(root.has_directory_child("parent", parent, "nested/name").is_err()); + } + + #[test] + fn held_root_and_ancestors_refuse_replacement_until_release() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("source"); + std::fs::create_dir_all(path.join("parent")).unwrap(); + let root = NativeRoot::open(&path).unwrap(); + assert!(std::fs::rename(&path, temporary.path().join("old-source")).is_err()); + let held = root.parent("parent").unwrap(); + assert!(std::fs::rename(path.join("parent"), path.join("old-parent")).is_err()); + drop(held); + let old = root.parent_identity("parent").unwrap(); + std::fs::rename(path.join("parent"), path.join("old-parent")).unwrap(); + std::fs::create_dir(path.join("parent")).unwrap(); + assert!(root.verify_parent("parent", old).is_err()); + root.verify().unwrap(); + drop(root); + std::fs::rename(&path, temporary.path().join("old-source")).unwrap(); + } + + #[test] + fn cancellation_and_early_exact_match_stop_enumeration() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("source"); + std::fs::create_dir(&path).unwrap(); + for number in 0..513 { + std::fs::create_dir(path.join(format!("child-{number:04}"))).unwrap(); + } + let root = NativeRoot::open(&path).unwrap(); + let mut checks = 0; + assert!(matches!( + root.children("", |_| { + checks += 1; + Err(IndexError::Interrupted) + }), + Err(IndexError::Interrupted) + )); + assert_eq!(checks, 1); + let mut lookup_checks = 0; + assert!(matches!( + root.has_directory_child_with_check("", root.identity, "missing", || { + lookup_checks += 1; + if lookup_checks == 3 { + Err(IndexError::Interrupted) + } else { + Ok(()) + } + }), + Err(IndexError::Interrupted) + )); + assert_eq!(lookup_checks, 3); + let held = root.parent("").unwrap(); + let mut records = 0; + visit(last(&held).unwrap(), |entry| { + if entry.is_some() { + records += 1; + return Ok(false); + } + Ok(true) + }) + .unwrap(); + assert_eq!(records, 1); + let mut rows = 0; + root.children("", |entry| { + if matches!(entry, Ok(Some(_))) { + rows += 1; + } + Ok(()) + }) + .unwrap(); + assert_eq!(rows, 513); + } + + #[test] + fn unsafe_windows_names_and_device_roots_are_refused() { + for name in [".", "..", "a/b", "a\\b", "stream:alternate", "wild*card", "name?", "tail.", "tail "] { + assert!(validate_name(name).is_err(), "Unexpected valid name: {name}"); + } + for path in [ + "relative", + "C:relative", + "\\\\.\\C:\\", + "\\\\?\\GLOBALROOT\\Device\\HarddiskVolume1\\", + ] { + assert!(root_path(Path::new(path)).is_err(), "Unexpected valid root: {path}"); + } + } +} diff --git a/companion/src-tauri/src/server/drives.rs b/companion/src-tauri/src/server/drives.rs index 4e48b23b3..e20a2c7a9 100644 --- a/companion/src-tauri/src/server/drives.rs +++ b/companion/src-tauri/src/server/drives.rs @@ -14,6 +14,314 @@ use log::warn; use super::models::{DriveInfo, DriveType}; +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub(super) struct VolumeEvidence { + pub class: DriveType, + pub volume: Option, + pub label: Option, + pub filesystem: Option, + pub capacity: Option, + pub separate_mount: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum VolumeChange { + Keep, + Index, + Generation, + Unavailable, +} + +impl VolumeEvidence { + pub fn retain_available(&mut self, previous: &Self) { + self.volume = self.volume.take().or_else(|| previous.volume.clone()); + self.label = self.label.take().or_else(|| previous.label.clone()); + self.filesystem = self.filesystem.take().or_else(|| previous.filesystem.clone()); + self.capacity = self.capacity.or(previous.capacity); + } + + pub fn removable(&self) -> bool { + matches!(self.class, DriveType::Removable | DriveType::Unknown) + } + + pub fn index_volume(&self) -> Option<&str> { + (!matches!(self.class, DriveType::Network | DriveType::Virtual)) + .then_some(self.volume.as_deref()) + .flatten() + } + + pub fn compare(&self, current: &Self) -> VolumeChange { + fn changed(stored: &Option, current: &Option) -> bool { + matches!((stored, current), (Some(stored), Some(current)) if stored != current) + } + if cfg!(target_os = "linux") && self.separate_mount && !current.separate_mount { + return VolumeChange::Unavailable; + } + if matches!(current.class, DriveType::Network | DriveType::Virtual) { + return VolumeChange::Keep; + } + if changed(&self.volume, ¤t.volume) { + return VolumeChange::Index; + } + if current.removable() + && (changed(&self.label, ¤t.label) + || changed(&self.filesystem, ¤t.filesystem) + || changed(&self.capacity, ¤t.capacity)) + { + return VolumeChange::Generation; + } + VolumeChange::Keep + } +} + +#[cfg(target_os = "linux")] +fn mount_field(value: &str) -> String { + value + .replace("\\040", " ") + .replace("\\011", "\t") + .replace("\\012", "\n") + .replace("\\134", "\\") +} + +#[cfg(target_os = "linux")] +pub(super) fn volume_evidence(root: &Path) -> std::io::Result { + use std::os::unix::ffi::OsStrExt; + std::fs::metadata(root)?; + let mounts = std::fs::read_to_string("/proc/self/mountinfo")?; + let mut selected = None; + for line in mounts.lines() { + let Some((mount, filesystem)) = line.split_once(" - ") else { + continue; + }; + let fields: Vec<_> = mount.split_whitespace().collect(); + let fs: Vec<_> = filesystem.split_whitespace().collect(); + if fields.len() < 5 || fs.len() < 2 { + continue; + } + let path = PathBuf::from(mount_field(fields[4])); + if root.starts_with(&path) + && selected + .as_ref() + .is_none_or(|(previous, _, _, _): &(PathBuf, String, String, String)| { + path.components().count() > previous.components().count() + }) + { + selected = Some((path, fields[2].to_owned(), fs[0].to_owned(), mount_field(fs[1]))); + } + } + let (mount, device, filesystem, source) = + selected.ok_or_else(|| std::io::Error::other("Directory mount information is unavailable."))?; + let udev = std::fs::read_to_string(format!("/run/udev/data/b{device}")).unwrap_or_default(); + let property = |name: &str| { + udev.lines() + .find_map(|line| line.strip_prefix(&format!("E:{name}=")).map(str::to_owned)) + }; + let mut volume = property("ID_FS_UUID"); + let mut label = property("ID_FS_LABEL"); + for (directory, target) in [("/dev/disk/by-uuid", &mut volume), ("/dev/disk/by-label", &mut label)] { + if target.is_none() { + if let (Ok(source), Ok(entries)) = (std::fs::canonicalize(&source), std::fs::read_dir(directory)) { + *target = entries + .filter_map(Result::ok) + .find(|entry| std::fs::canonicalize(entry.path()).is_ok_and(|path| path == source)) + .and_then(|entry| entry.file_name().into_string().ok()); + } + } + } + let class = linux_storage_class(&filesystem, &device, property("ID_BUS").as_deref()); + let name = std::ffi::CString::new(root.as_os_str().as_bytes())?; + let mut statistics: libc::statvfs = unsafe { std::mem::zeroed() }; + let capacity = (unsafe { libc::statvfs(name.as_ptr(), &mut statistics) } == 0) + .then(|| u64::try_from(u128::from(statistics.f_blocks) * u128::from(statistics.f_frsize)).ok()) + .flatten(); + Ok(VolumeEvidence { + class, + volume, + label, + filesystem: Some(filesystem), + capacity, + separate_mount: root == mount && mount != Path::new("/"), + }) +} + +#[cfg(target_os = "linux")] +fn linux_storage_class(filesystem: &str, device: &str, bus: Option<&str>) -> DriveType { + match filesystem { + "fuse" | "fuse.rclone" | "fuse.google-drive-ocamlfuse" | "fuse.gdfuse" | "fuse.dropbox" => return DriveType::Virtual, + "nfs" | "nfs4" | "cifs" | "smb" | "smbfs" | "9p" | "sshfs" => return DriveType::Network, + _ => {} + } + let Ok(device) = std::fs::canonicalize(format!("/sys/dev/block/{device}")) else { + return DriveType::Unknown; + }; + if matches!(bus, Some("usb" | "sd" | "mmc")) + || device.components().any(|component| { + component + .as_os_str() + .to_str() + .is_some_and(|name| name.starts_with("usb") || name.starts_with("mmc")) + }) + { + return DriveType::Removable; + } + for parent in device.ancestors() { + if let Ok(removable) = std::fs::read_to_string(parent.join("removable")) { + return match removable.trim() { + "0" => DriveType::Fixed, + "1" => DriveType::Removable, + _ => DriveType::Unknown, + }; + } + } + DriveType::Unknown +} + +#[cfg(not(any(target_os = "linux", target_os = "windows")))] +pub(super) fn volume_evidence(root: &Path) -> std::io::Result { + std::fs::metadata(root)?; + Ok(VolumeEvidence::default()) +} + +#[cfg(target_os = "windows")] +fn windows_hotplug_info(device: &std::fs::File) -> std::io::Result { + use std::os::windows::io::AsRawHandle; + use windows::Win32::Foundation::HANDLE; + use windows::Win32::System::Ioctl::{IOCTL_STORAGE_GET_HOTPLUG_INFO, STORAGE_HOTPLUG_INFO}; + use windows::Win32::System::IO::DeviceIoControl; + + let mut hotplug = STORAGE_HOTPLUG_INFO::default(); + let mut returned = 0; + unsafe { + DeviceIoControl( + HANDLE(device.as_raw_handle()), + IOCTL_STORAGE_GET_HOTPLUG_INFO, + None, + 0, + Some(std::ptr::from_mut(&mut hotplug).cast()), + size_of::() as u32, + Some(&mut returned), + None, + ) + } + .map_err(std::io::Error::from)?; + if (returned as usize) < size_of::() { + return Err(std::io::Error::other("Drive hot-plug information is incomplete.")); + } + Ok(hotplug) +} + +#[cfg(target_os = "windows")] +pub(super) fn volume_evidence(root: &Path) -> std::io::Result { + use std::os::windows::{ffi::OsStrExt, fs::OpenOptionsExt, io::AsRawHandle}; + use windows::core::PCWSTR; + use windows::Win32::Foundation::HANDLE; + use windows::Win32::Storage::FileSystem::{ + BusTypeMmc, BusTypeSd, BusTypeUsb, FileIdInfo, GetDiskFreeSpaceExW, GetDriveTypeW, GetFileInformationByHandleEx, + GetVolumeInformationW, GetVolumePathNameW, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_ID_INFO, + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, + }; + use windows::Win32::System::Ioctl::{ + PropertyStandardQuery, StorageDeviceProperty, IOCTL_STORAGE_QUERY_PROPERTY, STORAGE_DEVICE_DESCRIPTOR, STORAGE_PROPERTY_QUERY, + }; + use windows::Win32::System::IO::DeviceIoControl; + const VOLUME_PATH_UNITS: usize = 32768; + const VOLUME_NAME_UNITS: usize = 256; + const DEVICE_QUERY_BYTES: usize = 4096; + const DRIVE_REMOVABLE: u32 = 2; + const DRIVE_FIXED: u32 = 3; + const DRIVE_REMOTE: u32 = 4; + const DRIVE_CDROM: u32 = 5; + const DRIVE_RAMDISK: u32 = 6; + let path: Vec = root.as_os_str().encode_wide().chain(Some(0)).collect(); + let mut mount = vec![0u16; VOLUME_PATH_UNITS]; + unsafe { GetVolumePathNameW(PCWSTR(path.as_ptr()), &mut mount) }.map_err(std::io::Error::from)?; + let end = mount + .iter() + .position(|unit| *unit == 0) + .ok_or_else(|| std::io::Error::other("Invalid volume path."))?; + let mount_name = String::from_utf16(&mount[..end]).map_err(std::io::Error::other)?; + let mut evidence = VolumeEvidence::default(); + let raw = unsafe { GetDriveTypeW(PCWSTR(mount.as_ptr())) }; + evidence.class = match raw { + DRIVE_REMOVABLE | DRIVE_CDROM => DriveType::Removable, + DRIVE_REMOTE => DriveType::Network, + DRIVE_RAMDISK => DriveType::Virtual, + _ => DriveType::Unknown, + }; + if raw == DRIVE_FIXED { + let device_name = format!("\\\\.\\{}", mount_name.trim_end_matches('\\')); + if let Ok(device) = std::fs::OpenOptions::new() + .access_mode(0) + .share_mode(FILE_SHARE_READ.0 | FILE_SHARE_WRITE.0) + .open(device_name) + { + let query = STORAGE_PROPERTY_QUERY { + PropertyId: StorageDeviceProperty, + QueryType: PropertyStandardQuery, + ..Default::default() + }; + let mut buffer = [0u64; DEVICE_QUERY_BYTES / size_of::()]; + let mut returned = 0; + let descriptor = unsafe { + DeviceIoControl( + HANDLE(device.as_raw_handle()), + IOCTL_STORAGE_QUERY_PROPERTY, + Some(std::ptr::from_ref(&query).cast()), + size_of::() as u32, + Some(buffer.as_mut_ptr().cast()), + DEVICE_QUERY_BYTES as u32, + Some(&mut returned), + None, + ) + }; + match (descriptor, windows_hotplug_info(&device)) { + (Ok(()), Ok(hotplug)) if returned as usize >= size_of::() => { + let descriptor = unsafe { &*buffer.as_ptr().cast::() }; + evidence.class = if [BusTypeUsb, BusTypeSd, BusTypeMmc].contains(&descriptor.BusType) + || hotplug.DeviceHotplug + || hotplug.MediaRemovable + { + DriveType::Removable + } else { + DriveType::Fixed + }; + } + _ => {} + } + } + } + let file = std::fs::OpenOptions::new() + .access_mode(0) + .share_mode(FILE_SHARE_READ.0 | FILE_SHARE_WRITE.0 | FILE_SHARE_DELETE.0) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS.0 | FILE_FLAG_OPEN_REPARSE_POINT.0) + .open(root)?; + let mut identity = FILE_ID_INFO::default(); + if unsafe { + GetFileInformationByHandleEx( + HANDLE(file.as_raw_handle()), + FileIdInfo, + std::ptr::from_mut(&mut identity).cast(), + size_of::() as u32, + ) + } + .is_ok() + { + evidence.volume = Some(identity.VolumeSerialNumber.to_string()); + } + let mut label = [0u16; VOLUME_NAME_UNITS]; + let mut filesystem = [0u16; VOLUME_NAME_UNITS]; + if unsafe { GetVolumeInformationW(PCWSTR(mount.as_ptr()), Some(&mut label), None, None, None, Some(&mut filesystem)) }.is_ok() { + let text = |value: &[u16]| String::from_utf16(&value[..value.iter().position(|unit| *unit == 0).unwrap_or(value.len())]).ok(); + evidence.label = text(&label); + evidence.filesystem = text(&filesystem); + } + let mut capacity = 0; + if unsafe { GetDiskFreeSpaceExW(PCWSTR(mount.as_ptr()), None, Some(&mut capacity), None) }.is_ok() { + evidence.capacity = Some(capacity); + } + Ok(evidence) +} + /// Filesystem types to exclude (pseudo/virtual system filesystems). const EXCLUDED_FS_TYPES: &[&str] = &[ "proc", @@ -266,14 +574,9 @@ fn classify_linux_drive(fs_type: &str, mount_point: &str) -> DriveType { "fuse" | "fuse.rclone" | "fuse.google-drive-ocamlfuse" | "fuse.gdfuse" | "fuse.dropbox" => DriveType::Virtual, // Network filesystems "nfs" | "nfs4" | "cifs" | "smb" | "smbfs" | "9p" | "sshfs" => DriveType::Network, - _ => { - // Check if it's a removable device by looking at mount location - if mount_point.starts_with("/media/") || mount_point.starts_with("/mnt/") { - DriveType::Removable - } else { - DriveType::Fixed - } - } + _ => volume_evidence(Path::new(mount_point)) + .map(|evidence| evidence.class) + .unwrap_or(DriveType::Unknown), } } @@ -337,14 +640,7 @@ fn classify_macos_drive(path: &Path, name: &str) -> DriveType { #[cfg(target_os = "windows")] fn enumerate_platform_drives() -> Vec { - use windows::Win32::Storage::FileSystem::{GetDriveTypeW, GetLogicalDriveStringsW, GetVolumeInformationW}; - - // Stable Win32 drive type constants (from winbase.h) - const DRIVE_REMOVABLE: u32 = 2; - const DRIVE_FIXED: u32 = 3; - const DRIVE_REMOTE: u32 = 4; - const DRIVE_CDROM: u32 = 5; - const DRIVE_RAMDISK: u32 = 6; + use windows::Win32::Storage::FileSystem::{GetLogicalDriveStringsW, GetVolumeInformationW}; let mut buffer = [0u16; 512]; let len = unsafe { GetLogicalDriveStringsW(Some(&mut buffer)) } as usize; @@ -363,15 +659,9 @@ fn enumerate_platform_drives() -> Vec { let drive_str = String::from_utf16_lossy(&buffer[start..end]); let drive_wide: Vec = drive_str.encode_utf16().chain(std::iter::once(0)).collect(); - let raw_type = unsafe { GetDriveTypeW(windows::core::PCWSTR(drive_wide.as_ptr())) }; - let drive_type = match raw_type { - DRIVE_REMOVABLE => DriveType::Removable, - DRIVE_FIXED => DriveType::Fixed, - DRIVE_REMOTE => DriveType::Network, - DRIVE_CDROM => DriveType::Removable, - DRIVE_RAMDISK => DriveType::Virtual, - _ => DriveType::Unknown, - }; + let drive_type = volume_evidence(Path::new(&drive_str)) + .map(|evidence| evidence.class) + .unwrap_or(DriveType::Unknown); // Get volume label let mut vol_name = [0u16; 256]; @@ -449,3 +739,99 @@ fn default_root_drive() -> DriveInfo { drive_type: DriveType::Fixed, } } + +#[cfg(test)] +mod volume_tests { + use super::*; + + #[cfg(target_os = "windows")] + #[test] + fn system_volume_hotplug_query_returns_complete_information() { + use std::os::windows::fs::OpenOptionsExt; + use windows::Win32::Storage::FileSystem::{FILE_SHARE_READ, FILE_SHARE_WRITE}; + use windows::Win32::System::Ioctl::STORAGE_HOTPLUG_INFO; + + let drive = std::env::var("SystemDrive").expect("Windows test host must expose its system drive."); + let device = std::fs::OpenOptions::new() + .access_mode(0) + .share_mode(FILE_SHARE_READ.0 | FILE_SHARE_WRITE.0) + .open(format!("\\\\.\\{drive}")) + .unwrap(); + let information = windows_hotplug_info(&device).unwrap(); + assert_eq!(information.Size as usize, size_of::()); + } + + #[test] + fn volume_decisions_use_only_available_evidence_and_current_class() { + let stored = VolumeEvidence { + class: DriveType::Fixed, + volume: Some("first".into()), + label: Some("label".into()), + filesystem: Some("fs".into()), + capacity: Some(100), + separate_mount: false, + }; + for class in [ + DriveType::Fixed, + DriveType::Removable, + DriveType::Unknown, + DriveType::Network, + DriveType::Virtual, + ] { + let mut current = stored.clone(); + current.class = class; + assert_eq!(stored.compare(¤t), VolumeChange::Keep); + current.volume = Some("second".into()); + assert_eq!( + stored.compare(¤t), + if matches!(class, DriveType::Network | DriveType::Virtual) { + VolumeChange::Keep + } else { + VolumeChange::Index + } + ); + current.volume = None; + assert_eq!(stored.compare(¤t), VolumeChange::Keep); + for field in ["label", "filesystem", "capacity"] { + let mut changed = current.clone(); + match field { + "label" => changed.label = Some("renamed".into()), + "filesystem" => changed.filesystem = Some("other".into()), + _ => changed.capacity = Some(200), + } + assert_eq!( + stored.compare(&changed), + if matches!(class, DriveType::Removable | DriveType::Unknown) { + VolumeChange::Generation + } else { + VolumeChange::Keep + } + ); + } + } + } + + #[test] + fn missing_evidence_is_not_a_volume_change() { + let known = VolumeEvidence { + class: DriveType::Removable, + volume: Some("known".into()), + label: Some("label".into()), + filesystem: Some("fs".into()), + capacity: Some(100), + separate_mount: false, + }; + let missing = VolumeEvidence::default(); + assert_eq!(known.compare(&missing), VolumeChange::Keep); + assert_eq!(missing.compare(&known), VolumeChange::Keep); + #[cfg(target_os = "linux")] + assert_eq!( + VolumeEvidence { + separate_mount: true, + ..known + } + .compare(&missing), + VolumeChange::Unavailable + ); + } +} diff --git a/companion/src-tauri/src/server/handlers.rs b/companion/src-tauri/src/server/handlers.rs index 4f16a0142..5980777a9 100644 --- a/companion/src-tauri/src/server/handlers.rs +++ b/companion/src-tauri/src/server/handlers.rs @@ -75,6 +75,38 @@ use super::target_resolution::{ }; use super::{AppState, DownloadIntent}; +/// Shared directory-index coordinator for directory reads and namespace mutations. +#[derive(Clone)] +pub struct DirectoryMutationState(pub(super) Arc); + +impl axum::extract::FromRef> for DirectoryMutationState { + fn from_ref(state: &Arc) -> Self { + Self(Arc::clone(&state.directory_indexes)) + } +} + +impl DirectoryMutationState { + async fn create_parent(&self, drive: &str, root: &FsPath, destination: &FsPath) -> Result<(), std::io::Error> { + let parent = destination + .parent() + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "Destination has no parent directory."))? + .to_path_buf(); + let indexes = Arc::clone(&self.0); + let drive = drive.to_owned(); + let root = root.to_path_buf(); + let destination = destination.to_path_buf(); + tokio::task::spawn_blocking(move || { + indexes.run_observed_parent_creation(&drive, &root, &destination, || std::fs::create_dir_all(parent)) + }) + .await + .map_err(|error| { + std::io::Error::other(format!( + "Destination directory creation failed: {error}. Check the destination and retry." + )) + })? + } +} + /// Characters forbidden in file/directory names (matches backend validation). const FORBIDDEN_NAME_CHARS: &[char] = &['\\', '/', ':', '*', '?', '"', '<', '>', '|']; /// Prefix used by the frontend for synthetic local-drive connection IDs. @@ -1337,7 +1369,11 @@ fn resolve_viewer_path(drive: &str, query: &ViewerQuery) -> Result<(PathBuf, Str /// `DELETE /api/browse/{drive}/item` — delete a file or directory. /// /// Directories are removed recursively. Refuses to delete the drive root. -pub async fn browse_delete(Path(drive): Path, Query(query): Query) -> Result { +pub async fn browse_delete( + State(DirectoryMutationState(indexes)): State, + Path(drive): Path, + Query(query): Query, +) -> Result { let base_path = drives::resolve_drive_path(&drive).ok_or_else(|| ApiError::NotFound(format!("Unknown drive: {drive}")))?; let relative = normalize_drive_relative_path(&drive, query.path.as_deref().unwrap_or(""))?; @@ -1347,28 +1383,39 @@ pub async fn browse_delete(Path(drive): Path, Query(query): Query, Query(query): Query) -> Result { +pub async fn browse_delete_empty_directory( + State(DirectoryMutationState(indexes)): State, + Path(drive): Path, + Query(query): Query, +) -> Result { let base_path = drives::resolve_drive_path(&drive).ok_or_else(|| ApiError::NotFound(format!("Unknown drive: {drive}")))?; let relative = normalize_drive_relative_path(&drive, query.path.as_deref().unwrap_or(""))?; if relative.is_empty() { return Err(ApiError::BadRequest("Cannot delete the drive root".to_string())); } let directory = resolve_safe_path(&base_path, &drive, &relative)?; + super::directory_policy::ensure_mutation_allowed(&directory)?; let metadata = tokio::fs::symlink_metadata(&directory) .await .map_err(|error| map_io_error(error, &directory))?; @@ -1378,7 +1425,16 @@ pub async fn browse_delete_empty_directory(Path(drive): Path, Query(quer directory.display() ))); } - match tokio::fs::remove_dir(&directory).await { + let target = directory.clone(); + let outcome = + tokio::task::spawn_blocking(move || indexes.run_observed_operation(&drive, &base_path, &target, || std::fs::remove_dir(&target))) + .await + .map_err(|error| { + ApiError::Internal(format!( + "Empty-directory deletion task failed: {error}. Check the target and retry." + )) + })?; + match outcome { Ok(()) => Ok(StatusCode::NO_CONTENT), Err(error) if error.kind() == std::io::ErrorKind::DirectoryNotEmpty => Err(ApiError::conflict_message(format!( "Directory is not empty: {}", @@ -1397,7 +1453,11 @@ pub struct RenameRequest { /// `POST /api/browse/{drive}/rename` — rename a file or directory. /// /// Returns the updated `FileInfo` for the renamed item. -pub async fn browse_rename(Path(drive): Path, Json(body): Json) -> Result, ApiError> { +pub async fn browse_rename( + State(DirectoryMutationState(indexes)): State, + Path(drive): Path, + Json(body): Json, +) -> Result, ApiError> { let base_path = drives::resolve_drive_path(&drive).ok_or_else(|| ApiError::NotFound(format!("Unknown drive: {drive}")))?; validate_name(&body.new_name)?; @@ -1414,6 +1474,8 @@ pub async fn browse_rename(Path(drive): Path, Json(body): Json, Json(body): Json, Json(body): Json) -> Result, ApiError> { +pub async fn browse_create( + State(DirectoryMutationState(indexes)): State, + Path(drive): Path, + Json(body): Json, +) -> Result, ApiError> { let base_path = drives::resolve_drive_path(&drive).ok_or_else(|| ApiError::NotFound(format!("Unknown drive: {drive}")))?; validate_name(&body.name)?; @@ -1505,13 +1580,20 @@ pub async fn browse_create(Path(drive): Path, Json(body): Json { - tokio::fs::create_dir(&new_path).await.map_err(|e| map_io_error(e, &new_path))?; + let target = new_path.clone(); + tokio::task::spawn_blocking(move || { + indexes.run_observed_operation(&drive, &base_path, &target, || std::fs::create_dir(&target)) + }) + .await + .map_err(|error| ApiError::Internal(format!("Directory creation task failed: {error}. Check the target and retry.")))? + .map_err(|error| map_io_error(error, &new_path))?; } FileType::File => { File::create(&new_path).await.map_err(|e| map_io_error(e, &new_path))?; @@ -1942,7 +2024,11 @@ pub async fn browse_cancel_transfer_attempt(Path((_drive, attempt_id)): Path<(St /// /// Supports cross-drive copy when `dest_connection_id` specifies a /// different local drive (e.g. copying from drive C to drive D). -pub async fn browse_copy(Path(drive): Path, Json(body): Json) -> Result, ApiError> { +pub async fn browse_copy( + State(indexes): State, + Path(drive): Path, + Json(body): Json, +) -> Result, ApiError> { match reserve_transfer_receipt("copy", &drive, &body)? { TransferReceiptLookup::Owner => {} TransferReceiptLookup::Cached(outcome) => return replay_transfer_outcome(outcome), @@ -1958,7 +2044,7 @@ pub async fn browse_copy(Path(drive): Path, Json(body): Json Ok(finalize_transfer_result("copy", &drive, &body, result)), Err(error) => { complete_transfer_receipt("copy", &drive, &body, TransferReceiptOutcome::Error(transfer_receipt_error(&error))); @@ -1968,6 +2054,7 @@ pub async fn browse_copy(Path(drive): Path, Json(body): Json, @@ -1991,17 +2078,21 @@ async fn execute_browse_copy( )); } - let resolution = - match resolve_local_regular_file_transfer(&source, &dest, &source_meta, copy_move_target_policy(body)?, cancellation).await { - Ok(resolution) => resolution, - Err(LocalTransferWriteError::Io(error)) => return Err(map_io_error(error, &source)), - Err(LocalTransferWriteError::SourceChanged) => return Ok(source_changed_transfer_result(false)), - Err(LocalTransferWriteError::Cancelled) => return Ok(cancelled_transfer_result()), - Err(LocalTransferWriteError::TargetChanged) => { - return Err(build_conflict_error(&source, &dest, &body.source_path, &body.dest_path).await); - } - Err(LocalTransferWriteError::OutcomeUnknown) => return Ok(outcome_unknown_transfer_result()), - }; + let policy = copy_move_target_policy(body)?; + indexes + .create_parent(&dest_drive, &dest_base, &dest) + .await + .map_err(|error| map_io_error(error, &dest))?; + let resolution = match resolve_local_regular_file_transfer(&source, &dest, &source_meta, policy, cancellation).await { + Ok(resolution) => resolution, + Err(LocalTransferWriteError::Io(error)) => return Err(map_io_error(error, &source)), + Err(LocalTransferWriteError::SourceChanged) => return Ok(source_changed_transfer_result(false)), + Err(LocalTransferWriteError::Cancelled) => return Ok(cancelled_transfer_result()), + Err(LocalTransferWriteError::TargetChanged) => { + return Err(build_conflict_error(&source, &dest, &body.source_path, &body.dest_path).await); + } + Err(LocalTransferWriteError::OutcomeUnknown) => return Ok(outcome_unknown_transfer_result()), + }; if matches!( resolution.disposition, TargetResolutionDisposition::CreateNew | TargetResolutionDisposition::ReplaceExisting @@ -2030,7 +2121,11 @@ async fn execute_browse_copy( } /// `POST /api/browse/{drive}/move` — move an item within its local drive. -pub async fn browse_move(Path(drive): Path, Json(body): Json) -> Result, ApiError> { +pub async fn browse_move( + State(indexes): State, + Path(drive): Path, + Json(body): Json, +) -> Result, ApiError> { match reserve_transfer_receipt("move", &drive, &body)? { TransferReceiptLookup::Owner => {} TransferReceiptLookup::Cached(outcome) => return replay_transfer_outcome(outcome), @@ -2046,7 +2141,7 @@ pub async fn browse_move(Path(drive): Path, Json(body): Json Ok(finalize_transfer_result("move", &drive, &body, result)), Err(error) => { complete_transfer_receipt("move", &drive, &body, TransferReceiptOutcome::Error(transfer_receipt_error(&error))); @@ -2056,6 +2151,7 @@ pub async fn browse_move(Path(drive): Path, Json(body): Json, @@ -2067,6 +2163,7 @@ async fn execute_browse_move( let (dest_drive, dest_base) = resolve_dest_drive(drive, &body.dest_connection_id)?; validate_copy_move_paths(&body.source_path, &body.dest_path)?; let source = resolve_safe_path(&base_path, drive, &body.source_path)?; + super::directory_policy::ensure_mutation_allowed(&source)?; let source_meta = tokio::fs::metadata(&source).await.map_err(|error| map_io_error(error, &source))?; if !source_meta.is_file() { return Err(ApiError::BadRequest( @@ -2074,7 +2171,7 @@ async fn execute_browse_move( )); } if base_path != dest_base { - let copy_result = execute_browse_copy(drive, body, cancellation).await?; + let copy_result = execute_browse_copy(indexes, drive, body, cancellation).await?; if copy_result.status != "completed" { return Ok(copy_result); } @@ -2101,17 +2198,21 @@ async fn execute_browse_move( } let dest = resolve_safe_path_for_new(&dest_base, &dest_drive, &body.dest_path)?; - let resolution = - match resolve_local_regular_file_transfer(&source, &dest, &source_meta, copy_move_target_policy(body)?, cancellation).await { - Ok(resolution) => resolution, - Err(LocalTransferWriteError::Io(error)) => return Err(map_io_error(error, &source)), - Err(LocalTransferWriteError::SourceChanged) => return Ok(source_changed_transfer_result(false)), - Err(LocalTransferWriteError::Cancelled) => return Ok(cancelled_transfer_result()), - Err(LocalTransferWriteError::TargetChanged) => { - return Err(build_conflict_error(&source, &dest, &body.source_path, &body.dest_path).await); - } - Err(LocalTransferWriteError::OutcomeUnknown) => return Ok(outcome_unknown_transfer_result()), - }; + let policy = copy_move_target_policy(body)?; + indexes + .create_parent(&dest_drive, &dest_base, &dest) + .await + .map_err(|error| map_io_error(error, &dest))?; + let resolution = match resolve_local_regular_file_transfer(&source, &dest, &source_meta, policy, cancellation).await { + Ok(resolution) => resolution, + Err(LocalTransferWriteError::Io(error)) => return Err(map_io_error(error, &source)), + Err(LocalTransferWriteError::SourceChanged) => return Ok(source_changed_transfer_result(false)), + Err(LocalTransferWriteError::Cancelled) => return Ok(cancelled_transfer_result()), + Err(LocalTransferWriteError::TargetChanged) => { + return Err(build_conflict_error(&source, &dest, &body.source_path, &body.dest_path).await); + } + Err(LocalTransferWriteError::OutcomeUnknown) => return Ok(outcome_unknown_transfer_result()), + }; match resolution.disposition { TargetResolutionDisposition::Skip => return Ok(skipped_transfer_result()), TargetResolutionDisposition::AwaitCollision => { @@ -2151,6 +2252,7 @@ pub struct StreamTransferQuery { /// private sibling stage and publishes through the requested target policy, /// leaving an interrupted relay without partial output. pub async fn browse_stream_transfer( + State(indexes): State, Path(drive): Path, Query(query): Query, body: Body, @@ -2178,6 +2280,10 @@ pub async fn browse_stream_transfer( TargetResolutionDisposition::CreateNew | TargetResolutionDisposition::ReplaceExisting => {} } + indexes + .create_parent(&drive, &base_path, &destination) + .await + .map_err(|error| map_io_error(error, &destination))?; match stage_local_request_body_with_target_policy( &destination, body, @@ -2245,7 +2351,6 @@ async fn stage_local_request_body_with_target_policy( .file_name() .and_then(|value| value.to_str()) .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "Transfer target name is not valid UTF-8"))?; - tokio::fs::create_dir_all(parent).await?; let stage = parent.join(format!(".{target_name}.sambee-stage-{}", uuid::Uuid::new_v4())); let result: Result<(u64, bool), LocalTransferWriteError> = async { @@ -3231,6 +3336,9 @@ pub(crate) async fn start_direct_local_archive_session( .await?; } super::archive_sessions::ArchiveSessionKind::Extract => { + let temporary = tempfile::tempdir().unwrap(); + let indexes = + DirectoryMutationState(super::directory_manager::LocalDirectoryManager::new(temporary.path().join("private")).unwrap()); resolve_companion_extraction_coordinator( resolve_companion_archive_topology_plan(CompanionArchiveOperationKind::Extract, true, true) .map_err(map_local_archive_error)?, @@ -3238,7 +3346,7 @@ pub(crate) async fn start_direct_local_archive_session( .await? .into_plan(), )? - .execute() + .execute(indexes) .await?; } } @@ -3293,13 +3401,15 @@ pub(crate) async fn decide_direct_local_archive_session( expected_revision: u64, decision: super::archive_sessions::ArchiveSessionDecision, ) -> Result { + let temporary = tempfile::tempdir().unwrap(); + let indexes = DirectoryMutationState(super::directory_manager::LocalDirectoryManager::new(temporary.path().join("private")).unwrap()); resolve_companion_extraction_coordinator( resolve_companion_archive_topology_plan(CompanionArchiveOperationKind::Extract, true, true).map_err(map_local_archive_error)?, CompanionArchiveExtractionCoordinator::direct_local_from_session(state_store, execution_id.to_string()) .await? .into_plan(), )? - .decide(drive, owner_origin, execution_id, expected_revision, decision) + .decide(indexes, drive, owner_origin, execution_id, expected_revision, decision) .await } @@ -3789,10 +3899,10 @@ impl CompanionArchiveExtractionCoordinator { Ok(Self::direct_local(state_store, execution_id)) } - async fn execute(self) -> Result { + async fn execute(self, indexes: DirectoryMutationState) -> Result { match self.plan { CompanionArchiveExtractionPlan::DirectLocal { state_store, execution_id } => { - Self::execute_direct_local(state_store, execution_id).await?; + Self::execute_direct_local(indexes, state_store, execution_id).await?; Ok(CompanionArchiveExtractionResult::DirectLocalStarted) } } @@ -3800,6 +3910,7 @@ impl CompanionArchiveExtractionCoordinator { async fn decide( self, + indexes: DirectoryMutationState, drive: &str, owner_origin: &str, execution_id: &str, @@ -3855,6 +3966,7 @@ impl CompanionArchiveExtractionCoordinator { .resume_live_extraction(drive, owner_origin, execution_id, expected_revision, decision) .await?; Self::spawn_direct_local_extraction( + indexes, state_store, execution_id.to_string(), drive_root, @@ -3902,7 +4014,11 @@ fn resolve_companion_extraction_coordinator( } impl CompanionArchiveExtractionCoordinator { - async fn execute_direct_local(state_store: Arc, execution_id: String) -> Result<(), ApiError> { + async fn execute_direct_local( + indexes: DirectoryMutationState, + state_store: Arc, + execution_id: String, + ) -> Result<(), ApiError> { let (drive_root, _archive_path, destination_path, cancellation_requested, source) = match state_store.start_live_extraction(&execution_id).await { Ok(state) => state, @@ -3914,6 +4030,7 @@ impl CompanionArchiveExtractionCoordinator { } }; Self::spawn_direct_local_extraction( + indexes, state_store, execution_id, drive_root, @@ -3925,6 +4042,7 @@ impl CompanionArchiveExtractionCoordinator { } fn spawn_direct_local_extraction( + indexes: DirectoryMutationState, state_store: Arc, execution_id: String, drive_root: PathBuf, @@ -3935,12 +4053,12 @@ impl CompanionArchiveExtractionCoordinator { tokio::spawn(async move { let worker_source = source.clone(); let result = tokio::task::spawn_blocking(move || { - extract_live_local_archive(&drive_root, &destination_path, &cancellation_requested, &worker_source).map(|result| { - match result { + extract_live_local_archive(&indexes, &drive_root, &destination_path, &cancellation_requested, &worker_source).map( + |result| match result { DirectLiveExtractionRun::Completed(result) => ArchiveSessionCompletion::ExtractionCompletedLive { result }, DirectLiveExtractionRun::AwaitingCollision { result } => ArchiveSessionCompletion::AwaitingLiveCollision { result }, - } - }) + }, + ) }) .await .map_err(|error| LocalArchiveError::Io(std::io::Error::other(format!("Archive execution task failed: {error}")))) @@ -3964,12 +4082,15 @@ impl CompanionArchiveExtractionCoordinator { } fn extract_live_local_archive( + indexes: &DirectoryMutationState, drive_root: &FsPath, destination_path: &FsPath, cancellation_requested: &AtomicBool, source: &AsyncMutex, ) -> Result { - create_local_extraction_root(drive_root, destination_path)?; + indexes.0.run_observed_root_creation(drive_root, destination_path, || { + create_local_extraction_root(drive_root, destination_path) + })?; loop { if cancellation_requested.load(Ordering::Acquire) { return Err(LocalArchiveError::Cancelled); @@ -3988,7 +4109,9 @@ fn extract_live_local_archive( let target_path = destination_path.join(&relative_target_path); let target_path_text = target_path.to_string_lossy().to_string(); let result = if member.entry.is_directory { - let directories_created = match ensure_local_extraction_directory(drive_root, destination_path, &target_path)? { + let directories_created = match indexes.0.run_observed_root_creation(drive_root, &target_path, || { + ensure_local_extraction_directory(drive_root, destination_path, &target_path) + })? { LocalArchiveDirectoryOutput::Ready { directories_created } => directories_created, LocalArchiveDirectoryOutput::AwaitCollision { target } => { return pause_live_local_collision(source, &member, relative_target_path, target); @@ -4006,8 +4129,9 @@ fn extract_live_local_archive( renamed: false, } } else { - match prepare_local_archive_target_output(drive_root, destination_path, &target_path, target_policy, member.entry.modified_at)? - { + match indexes.0.run_observed_root_creation(drive_root, &target_path, || { + prepare_local_archive_target_output(drive_root, destination_path, &target_path, target_policy, member.entry.modified_at) + })? { LocalArchiveTargetOutput::Ready { mut file, replaced, @@ -4445,7 +4569,7 @@ async fn start_v2_local_archive_execution( .await? .into_plan(), )? - .execute() + .execute(DirectoryMutationState(Arc::clone(&state.directory_indexes))) .await?; if !matches!(result, CompanionArchiveExtractionResult::DirectLocalStarted) { return Err(ApiError::Internal( @@ -4551,6 +4675,7 @@ async fn decide_v2_local_archive_execution( .into_plan(), )? .decide( + DirectoryMutationState(Arc::clone(&state.directory_indexes)), &drive, &extract_origin(&headers)?, &execution_id, @@ -5016,7 +5141,12 @@ async fn execute_relay_extraction_to_local_destination( let relay = ArchiveExtractionRelay::from_transport(transport); Ok(Json( execute_archive_relay( - extract_smb_archive_to_local_live(&relay, drive_root, destination_path), + extract_smb_archive_to_local_live( + DirectoryMutationState(Arc::clone(&state.directory_indexes)), + &relay, + drive_root, + destination_path, + ), relay.fail(), ) .await?, @@ -5045,6 +5175,7 @@ pub async fn browse_relay_v2_archive_extraction( } async fn extract_smb_archive_to_local_live( + indexes: DirectoryMutationState, relay: &ArchiveExtractionRelay, drive_root: PathBuf, destination_path: PathBuf, @@ -5052,10 +5183,15 @@ async fn extract_smb_archive_to_local_live( relay.begin_live_remote_source().await?; let root = destination_path.clone(); let root_drive = drive_root.clone(); - let _root_created = tokio::task::spawn_blocking(move || create_local_extraction_root(&root_drive, &root)) - .await - .map_err(|error| ApiError::Internal(format!("Local archive root task failed: {error}")))? - .map_err(map_local_archive_error)?; + let root_indexes = indexes.clone(); + let _root_created = tokio::task::spawn_blocking(move || { + root_indexes + .0 + .run_observed_root_creation(&root_drive, &root, || create_local_extraction_root(&root_drive, &root)) + }) + .await + .map_err(|error| ApiError::Internal(format!("Local archive root task failed: {error}")))? + .map_err(map_local_archive_error)?; loop { let Some(member) = relay.next_live_remote_source_member().await? else { return Ok(live_remote_source_response(relay.complete_live_remote_source().await?)); @@ -5069,10 +5205,15 @@ async fn extract_smb_archive_to_local_live( let target_path_text = target_path.to_string_lossy().to_string(); if member.is_directory { let output = tokio::task::spawn_blocking({ + let indexes = indexes.clone(); let drive_root = drive_root.clone(); let destination_path = destination_path.clone(); let target_path = target_path.clone(); - move || ensure_local_extraction_directory(&drive_root, &destination_path, &target_path) + move || { + indexes.0.run_observed_root_creation(&drive_root, &target_path, || { + ensure_local_extraction_directory(&drive_root, &destination_path, &target_path) + }) + } }) .await .map_err(|error| ApiError::Internal(format!("Local archive directory task failed: {error}")))? @@ -5106,11 +5247,16 @@ async fn extract_smb_archive_to_local_live( continue; } let output = tokio::task::spawn_blocking({ + let indexes = indexes.clone(); let drive_root = drive_root.clone(); let destination_path = destination_path.clone(); let target_path = target_path.clone(); let modified_at = member.modified_at; - move || prepare_local_archive_target_output(&drive_root, &destination_path, &target_path, target_policy, modified_at) + move || { + indexes.0.run_observed_root_creation(&drive_root, &target_path, || { + prepare_local_archive_target_output(&drive_root, &destination_path, &target_path, target_policy, modified_at) + }) + } }) .await .map_err(|error| ApiError::Internal(format!("Local archive file task failed: {error}")))? @@ -5444,18 +5590,35 @@ fn map_local_archive_read_error(error: super::archive::LocalArchiveReadError) -> // ─── Directory Search ──────────────────────────────────────────────────────── -/// Maximum number of directory results to return per query. -const MAX_SEARCH_RESULTS: usize = 50; +pub async fn directory_policy_status( + State(state): State>, + headers: HeaderMap, +) -> Result, ApiError> { + let origin = extract_origin(&headers)?; + state.directory_policy.status(Some(&origin)).map(Json) +} -/// Maximum number of directories to index per drive. -/// -/// Prevents runaway recursion on very large or deep filesystems. -const MAX_DIRECTORY_SCAN: usize = 100_000; +pub async fn directory_policy_challenge( + State(state): State>, + headers: HeaderMap, +) -> Result, ApiError> { + let origin = extract_origin(&headers)?; + state.directory_policy.challenge(&origin, &state.app).map(Json) +} + +pub async fn directory_policy_redeem( + State(state): State>, + headers: HeaderMap, + Json(delivery): Json, +) -> Result, ApiError> { + let origin = extract_origin(&headers)?; + state.directory_policy.redeem(&origin, delivery).await.map(Json) +} /// Query parameters for directory search. #[derive(Deserialize)] pub struct DirectorySearchQuery { - /// Search query string (case-insensitive substring match on path). + /// Independent literal terms matched against directory paths. pub q: Option, /// Whether to include directories with dot-prefixed path segments. pub include_dot_directories: Option, @@ -5463,103 +5626,30 @@ pub struct DirectorySearchQuery { /// `GET /api/browse/{drive}/directories` — search for directories on a local drive. /// -/// Recursively walks the drive, collects directory paths, and returns those -/// matching the query string `q`. Produces the same JSON shape as the -/// backend's `DirectorySearchResult`. -/// -/// Unlike the backend, this does not maintain a persistent cache — it walks -/// the filesystem on each request. For typical local drives this is fast -/// enough (< 1s for tens of thousands of directories). +/// Starts or joins a background catalogue and queries its committed rows. pub async fn browse_search_directories( + State(state): State, Path(drive): Path, Query(query): Query, ) -> Result, ApiError> { let base_path = drives::resolve_drive_path(&drive).ok_or_else(|| ApiError::NotFound(format!("Unknown drive: {drive}")))?; - let canonical_base = std::fs::canonicalize(&base_path).map_err(|e| ApiError::NotFound(format!("Drive root inaccessible: {e}")))?; - let q = query.q.unwrap_or_default(); let include_dot_directories = query.include_dot_directories.unwrap_or(false); - - // Walk the directory tree and collect directory paths - let directories = tokio::task::spawn_blocking({ - let base = canonical_base.clone(); - move || walk_directories(&base, MAX_DIRECTORY_SCAN, include_dot_directories) - }) - .await - .map_err(|e| ApiError::Internal(format!("Directory scan task failed: {e}")))?; - - let directory_count = directories.len(); - - // Filter by query (case-insensitive substring match) - let (results, total_matches) = if q.is_empty() { - (Vec::new(), 0) - } else { - let q_lower = q.to_lowercase(); - let matching: Vec = directories - .into_iter() - .filter(|path| path.to_lowercase().contains(&q_lower)) - .collect(); - let total = matching.len(); - let truncated = matching.into_iter().take(MAX_SEARCH_RESULTS).collect(); - (truncated, total) - }; - - Ok(Json(DirectorySearchResult { - results, - total_matches, - cache_state: "ready".to_string(), - directory_count, - })) -} - -/// Recursively walk a directory tree and collect relative directory paths. -/// -/// Optionally skips hidden directories (starting with `.`) and stops after -/// `max_dirs` directories to prevent runaway scans on very large filesystems. -fn walk_directories(base: &std::path::Path, max_dirs: usize, include_dot_directories: bool) -> Vec { - let mut result = Vec::new(); - let mut stack: Vec = vec![base.to_path_buf()]; - - while let Some(dir) = stack.pop() { - if result.len() >= max_dirs { - break; - } - - let entries = match std::fs::read_dir(&dir) { - Ok(e) => e, - Err(_) => continue, // skip unreadable directories - }; - - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - - let name = entry.file_name(); - let name_str = name.to_string_lossy(); - - // Skip hidden directories - if !include_dot_directories && name_str.starts_with('.') { - continue; - } - - // Build relative path - if let Ok(rel) = path.strip_prefix(base) { - let rel_str = rel.to_string_lossy().replace('\\', "/"); - result.push(rel_str); - stack.push(path); - } - - if result.len() >= max_dirs { - break; + super::directory_index::search_terms(&q).map_err(|error| ApiError::BadRequest(error.to_string()))?; + state + .0 + .search(drive, base_path, q, include_dot_directories) + .await + .map(Json) + .map_err(|error| match error { + super::directory_index::IndexError::QueryBusy => ApiError::TooManyRequests(error.to_string()), + super::directory_index::IndexError::Invalid(message) => ApiError::BadRequest(message.to_owned()), + super::directory_index::IndexError::Interrupted => { + ApiError::TooManyRequests("Directory query was cancelled or timed out. Retry the search.".to_owned()) } - } - } - - result.sort(); - result + _ => ApiError::Internal("Directory search failed. Check drive access and private local storage, then retry.".to_owned()), + }) } // ─── Upload ────────────────────────────────────────────────────────────────── @@ -5772,7 +5862,10 @@ pub async fn browse_upload( // Ensure parent directory exists if let Some(parent) = dest.parent() { - tokio::fs::create_dir_all(parent).await.map_err(|e| map_io_error(e, parent))?; + DirectoryMutationState(Arc::clone(&state.directory_indexes)) + .create_parent(&drive, &base_path, &dest) + .await + .map_err(|error| map_io_error(error, parent))?; } let size = data.len() as u64; @@ -5790,7 +5883,7 @@ pub async fn browse_upload( dt.to_rfc3339() }); - log::info!("Uploaded {} bytes to {}", size, dest.display()); + log::info!("Local upload completed: bytes={size}"); Ok(Json(UploadResponse { status: "ok".to_string(), @@ -6310,6 +6403,7 @@ fn resolve_safe_path_for_new(base: &std::path::Path, drive: &str, relative: &str } } + super::directory_policy::ensure_mutation_allowed(&full)?; Ok(full) } @@ -6614,9 +6708,6 @@ async fn resolve_local_regular_file_transfer( policy: TargetResolutionPolicy, cancellation: Option<&AtomicBool>, ) -> Result, LocalTransferWriteError> { - if let Some(parent) = dest.parent() { - tokio::fs::create_dir_all(parent).await.map_err(LocalTransferWriteError::Io)?; - } resolve_target_mutation_attempt( ContentTransferPlan { source_modified_at: expected_source.modified().ok().map(DateTime::::from), @@ -6905,6 +6996,243 @@ async fn rename_noreplace(_source: &FsPath, _destination: &FsPath) -> Result<(), #[cfg(test)] mod tests { + fn directory_mutation_state(directory: &std::path::Path) -> axum::extract::State { + axum::extract::State(super::DirectoryMutationState( + super::super::directory_manager::LocalDirectoryManager::new(directory.join("index-cache")).unwrap(), + )) + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn local_directory_mutation_handlers_reconcile_the_background_index() { + const SETTLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + const RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10); + const FRESHNESS_TARGET: std::time::Duration = std::time::Duration::from_secs(2); + + async fn wait_for_paths( + state: &super::DirectoryMutationState, + drive: &str, + root: &std::path::Path, + expected: &[&str], + ) -> super::DirectorySearchResult { + let mut last_status = String::new(); + let settled = tokio::time::timeout(SETTLE_TIMEOUT, async { + loop { + let result = match state + .0 + .search(drive.to_owned(), root.to_path_buf(), "created renamed".to_owned(), false) + .await + { + Ok(result) => result, + Err( + error @ (super::super::directory_index::IndexError::Interrupted + | super::super::directory_index::IndexError::QueryBusy), + ) => { + last_status = error.to_string(); + tokio::time::sleep(RETRY_INTERVAL).await; + continue; + } + Err(error) => panic!("background directory query failed: {error}"), + }; + if result.cache_state == "ready" + && result.last_verified_at_ms.is_some() + && result.results.iter().map(String::as_str).collect::>() == expected + && result.directory_count == expected.len() + { + return result; + } + last_status = serde_json::to_string(&result).unwrap(); + tokio::time::sleep(RETRY_INTERVAL).await; + } + }) + .await; + settled.unwrap_or_else(|_| { + panic!("background directory reconciliation should finish; expected {expected:?}; last status: {last_status}") + }) + } + + let directory = tempfile::tempdir().unwrap(); + let cache = tempfile::tempdir().unwrap(); + let drive = format!("mutation-{}", uuid::Uuid::new_v4()); + super::drives::register_test_drive_path(drive.clone(), directory.path().to_path_buf()); + let state = directory_mutation_state(cache.path()); + let before = wait_for_paths(&state.0, &drive, directory.path(), &[]).await; + let started = std::time::Instant::now(); + let created_info = super::browse_create( + state.clone(), + axum::extract::Path(drive.clone()), + axum::Json(super::CreateItemRequest { + parent_path: String::new(), + name: "created".to_owned(), + item_type: super::FileType::Directory, + }), + ) + .await + .unwrap(); + assert_eq!(created_info.0.path, "created"); + let created = wait_for_paths(&state.0, &drive, directory.path(), &["created"]).await; + eprintln!( + "directory_mutation_fixture operation=create elapsed_ms={} target_ms={}", + started.elapsed().as_millis(), + FRESHNESS_TARGET.as_millis() + ); + assert_eq!(created.last_verified_at_ms, before.last_verified_at_ms); + let started = std::time::Instant::now(); + let renamed_info = super::browse_rename( + state.clone(), + axum::extract::Path(drive.clone()), + axum::Json(super::RenameRequest { + path: "created".to_owned(), + new_name: "renamed".to_owned(), + }), + ) + .await + .unwrap(); + assert_eq!(renamed_info.0.path, "renamed"); + let renamed = wait_for_paths(&state.0, &drive, directory.path(), &["renamed"]).await; + eprintln!( + "directory_mutation_fixture operation=rename elapsed_ms={} target_ms={}", + started.elapsed().as_millis(), + FRESHNESS_TARGET.as_millis() + ); + assert_eq!(renamed.last_verified_at_ms, before.last_verified_at_ms); + let started = std::time::Instant::now(); + assert_eq!( + super::browse_delete_empty_directory( + state.clone(), + axum::extract::Path(drive.clone()), + axum::extract::Query(super::BrowseQuery { + path: Some("renamed".to_owned()) + }), + ) + .await + .unwrap(), + axum::http::StatusCode::NO_CONTENT + ); + let removed = wait_for_paths(&state.0, &drive, directory.path(), &[]).await; + eprintln!( + "directory_mutation_fixture operation=delete elapsed_ms={} target_ms={}", + started.elapsed().as_millis(), + FRESHNESS_TARGET.as_millis() + ); + assert_eq!(removed.last_verified_at_ms, before.last_verified_at_ms); + assert!(!directory.path().join("created").exists()); + assert!(!directory.path().join("renamed").exists()); + + std::fs::create_dir_all(directory.path().join("created/nested/child")).unwrap(); + let nested = wait_for_paths( + &state.0, + &drive, + directory.path(), + &["created", "created/nested", "created/nested/child"], + ) + .await; + assert_eq!( + super::browse_delete( + state.clone(), + axum::extract::Path(drive.clone()), + axum::extract::Query(super::BrowseQuery { + path: Some("created".to_owned()) + }), + ) + .await + .unwrap(), + axum::http::StatusCode::NO_CONTENT + ); + let recursively_removed = wait_for_paths(&state.0, &drive, directory.path(), &[]).await; + assert_eq!(recursively_removed.last_verified_at_ms, nested.last_verified_at_ms); + assert!(!directory.path().join("created").exists()); + + for complete in [true, false] { + let result = super::browse_stream_transfer( + state.clone(), + axum::extract::Path(drive.clone()), + axum::extract::Query(super::StreamTransferQuery { + path: "created/nested/file.txt".to_owned(), + target_resolution_policy: Some("ask".to_owned()), + expected_size: if complete { 4 } else { 5 }, + source_modified_at: None, + }), + super::Body::from("data"), + ) + .await; + if complete { + assert_eq!(result.unwrap().0.status, "completed"); + } else { + assert!(matches!(result, Err(super::ApiError::UnprocessableContent(_)))); + } + let transferred = wait_for_paths(&state.0, &drive, directory.path(), &["created", "created/nested"]).await; + assert_eq!(transferred.last_verified_at_ms, recursively_removed.last_verified_at_ms); + assert_eq!(directory.path().join("created/nested/file.txt").exists(), complete); + assert_eq!( + super::browse_delete( + state.clone(), + axum::extract::Path(drive.clone()), + axum::extract::Query(super::BrowseQuery { + path: Some("created".to_owned()) + }), + ) + .await + .unwrap(), + axum::http::StatusCode::NO_CONTENT + ); + wait_for_paths(&state.0, &drive, directory.path(), &[]).await; + } + + let archive_path = directory.path().join("fixture.zip"); + let mut archive = zip::ZipWriter::new(std::fs::File::create(&archive_path).unwrap()); + archive + .start_file("nested/child/data.txt", zip::write::SimpleFileOptions::default()) + .unwrap(); + std::io::Write::write_all(&mut archive, b"data").unwrap(); + archive.finish().unwrap(); + let sessions = std::sync::Arc::new(super::ArchiveSessionManager::new()); + let origin = "https://sambee.example"; + let execution = sessions + .create_extraction( + drive.clone(), + origin.to_owned(), + directory.path().to_path_buf(), + archive_path, + directory.path().join("created"), + ) + .await; + let coordinator = super::CompanionArchiveExtractionCoordinator::direct_local_from_session( + std::sync::Arc::clone(&sessions), + execution.execution_id.clone(), + ) + .await + .unwrap(); + assert!(matches!( + coordinator.execute(state.0.clone()).await.unwrap(), + super::CompanionArchiveExtractionResult::DirectLocalStarted + )); + tokio::time::timeout(SETTLE_TIMEOUT, async { + loop { + let status = sessions.get(&drive, origin, &execution.execution_id).await.unwrap(); + match status.phase { + super::super::archive_sessions::ArchiveSessionPhase::Completed => break, + super::super::archive_sessions::ArchiveSessionPhase::Failed => panic!("archive extraction failed: {status:?}"), + _ => tokio::time::sleep(RETRY_INTERVAL).await, + } + } + }) + .await + .unwrap(); + let extracted = wait_for_paths( + &state.0, + &drive, + directory.path(), + &["created", "created/nested", "created/nested/child"], + ) + .await; + assert_eq!(extracted.last_verified_at_ms, recursively_removed.last_verified_at_ms); + assert_eq!( + std::fs::read(directory.path().join("created/nested/child/data.txt")).unwrap(), + b"data" + ); + } + #[tokio::test] async fn directory_listing_preserves_drive_boundaries() { let directory = tempfile::tempdir().expect("temporary drive should be created"); @@ -7113,6 +7441,7 @@ mod tests { } let response = super::browse_rename( + directory_mutation_state(directory.path()), axum::extract::Path(drive.clone()), axum::Json(super::RenameRequest { path: old_name.to_string(), @@ -7141,6 +7470,7 @@ mod tests { .expect("target should be created"); let response = super::browse_rename( + directory_mutation_state(directory.path()), axum::extract::Path(drive), axum::Json(super::RenameRequest { path: "source.txt".to_string(), @@ -7167,6 +7497,7 @@ mod tests { for alias in ["hard-link.txt", "symbolic-link.txt"] { let response = super::browse_rename( + directory_mutation_state(directory.path()), axum::extract::Path(drive.clone()), axum::Json(super::RenameRequest { path: "source.txt".to_string(), @@ -7193,6 +7524,7 @@ mod tests { std::fs::hard_link(&source, &target).expect("case-variant hard link should be created"); let response = super::browse_rename( + directory_mutation_state(directory.path()), axum::extract::Path(drive), axum::Json(super::RenameRequest { path: "A.PDF".to_string(), @@ -7497,7 +7829,9 @@ mod tests { #[tokio::test] async fn move_route_validates_the_source_drive() { + let cache = tempfile::tempdir().unwrap(); let response = super::browse_move( + directory_mutation_state(cache.path()), Path("missing-drive".to_string()), axum::Json(CopyMoveRequest { source_path: "source.txt".to_string(), @@ -7526,6 +7860,7 @@ mod tests { tokio::fs::write(&source, b"report").await.expect("source should be written"); let result = super::execute_browse_move( + &directory_mutation_state(destination_directory.path()).0, &source_drive, &CopyMoveRequest { source_path: "report.txt".to_string(), @@ -7566,6 +7901,7 @@ mod tests { .expect("source directory should be written"); let result = super::execute_browse_copy( + &directory_mutation_state(destination_directory.path()).0, &source_drive, &CopyMoveRequest { source_path: "documents".to_string(), @@ -7604,6 +7940,7 @@ mod tests { for policy in ["ask", "skip", "replace", "replace_older"] { let result = super::execute_browse_copy( + &directory_mutation_state(destination_directory.path()).0, &source_drive, &CopyMoveRequest { source_path: "documents".to_string(), @@ -7642,6 +7979,7 @@ mod tests { tokio::fs::create_dir(&source).await.expect("source directory should be written"); let result = super::execute_browse_move( + &directory_mutation_state(destination_directory.path()).0, &source_drive, &CopyMoveRequest { source_path: "documents".to_string(), @@ -7726,6 +8064,7 @@ mod tests { tokio::fs::write(&target, b"existing").await.expect("target should be written"); let response = browse_stream_transfer( + directory_mutation_state(directory.path()), Path(drive), Query(StreamTransferQuery { path: "target.txt".to_string(), @@ -7751,6 +8090,7 @@ mod tests { tokio::fs::write(&target, b"existing").await.expect("target should be written"); let response = browse_stream_transfer( + directory_mutation_state(directory.path()), Path(drive), Query(StreamTransferQuery { path: "target.txt".to_string(), @@ -7784,6 +8124,7 @@ mod tests { for source_modified_at in [target_mtime - chrono::Duration::seconds(1), target_mtime] { let response = browse_stream_transfer( + directory_mutation_state(directory.path()), Path(drive.clone()), Query(StreamTransferQuery { path: "target.txt".to_string(), @@ -7801,6 +8142,7 @@ mod tests { } let response = browse_stream_transfer( + directory_mutation_state(directory.path()), Path(drive), Query(StreamTransferQuery { path: "target.txt".to_string(), @@ -7827,6 +8169,7 @@ mod tests { tokio::fs::write(&target, b"existing").await.expect("target should be written"); let response = browse_stream_transfer( + directory_mutation_state(directory.path()), Path(drive), Query(StreamTransferQuery { path: "target.txt".to_string(), @@ -9423,9 +9766,14 @@ mod tests { playback.url.clone(), "test-token".to_string(), )); - let result = extract_smb_archive_to_local_live(&relay, directory.path().to_path_buf(), destination_path.clone()) - .await - .expect("SMB-to-local live relay should complete"); + let result = extract_smb_archive_to_local_live( + directory_mutation_state(directory.path()).0, + &relay, + directory.path().to_path_buf(), + destination_path.clone(), + ) + .await + .expect("SMB-to-local live relay should complete"); assert_eq!( ( @@ -9563,9 +9911,14 @@ mod tests { playback.url.clone(), "test-token".to_string(), )); - let result = extract_smb_archive_to_local_live(&relay, directory.path().to_path_buf(), destination_path) - .await - .expect("source integrity failure should surface the live retry decision"); + let result = extract_smb_archive_to_local_live( + directory_mutation_state(directory.path()).0, + &relay, + directory.path().to_path_buf(), + destination_path, + ) + .await + .expect("source integrity failure should surface the live retry decision"); assert_eq!(result.phase, Some("awaiting_user_decision")); assert_eq!(result.members_processed, 0); diff --git a/companion/src-tauri/src/server/mod.rs b/companion/src-tauri/src/server/mod.rs index 13a720443..6c4c94d2c 100644 --- a/companion/src-tauri/src/server/mod.rs +++ b/companion/src-tauri/src/server/mod.rs @@ -7,6 +7,10 @@ pub mod archive; pub mod archive_sessions; pub mod auth; +pub mod directory_index; +mod directory_manager; +pub mod directory_policy; +mod directory_traversal; pub mod drives; pub mod edit_locks; pub mod errors; @@ -15,6 +19,8 @@ pub mod links; pub mod localization; pub mod models; pub mod pairing; +#[cfg(feature = "recovery-verification")] +mod recovery_verification; pub mod target_resolution; pub mod watcher; @@ -25,11 +31,12 @@ use std::time::Instant; use axum::http::{header, Method}; use axum::Router; use log::{error, info}; -use tauri::AppHandle; +use tauri::{AppHandle, Manager}; use tower_http::cors::{AllowOrigin, CorsLayer}; use self::archive_sessions::ArchiveSessionManager; use self::auth::AuthState; +use self::directory_manager::LocalDirectoryManager; use self::edit_locks::EditLockManager; use self::localization::LocalizationState; use self::pairing::PairingState; @@ -56,6 +63,8 @@ pub struct AppState { pub archive_sessions: Arc, pub edit_locks: Arc, pub watcher: DirectoryWatcher, + pub directory_indexes: Arc, + pub directory_policy: Arc, } /// Start the local HTTP API server. @@ -78,6 +87,27 @@ async fn run_server( localization: Arc, ) -> Result<(), Box> { archive::cleanup_stale_temporary_archive_downloads()?; + let namespace = app.path().app_cache_dir()?.join("directory-index").join("sqlite"); + #[cfg(feature = "recovery-verification")] + let namespace = std::env::var_os(recovery_verification::ROOT_ENV) + .map(|root| std::path::PathBuf::from(root).with_extension("cache")) + .unwrap_or(namespace); + let directory_indexes = LocalDirectoryManager::new(namespace)?; + let policy_namespace = app.path().app_config_dir()?.join("directory-index-policy"); + #[cfg(feature = "recovery-verification")] + let policy_namespace = std::env::var_os(recovery_verification::ROOT_ENV) + .map(|root| std::path::PathBuf::from(root).with_extension("policy")) + .unwrap_or(policy_namespace); + let directory_policy = directory_policy::DirectoryPolicyState::open(policy_namespace, &directory_indexes) + .map_err(|_| std::io::Error::other("Approved directory policy storage is unavailable. Correct private configuration storage."))?; + app.manage(Arc::clone(&directory_policy)); + directory_policy + .protect_storage(directory_indexes.storage_directory()) + .map_err(|_| { + std::io::Error::other( + "Approved policy storage cannot be protected. Correct private configuration storage and restart the companion.", + ) + })?; let state = Arc::new(AppState { app, pairing, @@ -86,6 +116,8 @@ async fn run_server( archive_sessions: Arc::new(ArchiveSessionManager::new()), edit_locks: Arc::new(EditLockManager::new()), watcher: DirectoryWatcher::new(), + directory_indexes, + directory_policy, }); let app = build_router(state); @@ -125,6 +157,15 @@ fn build_router(state: Arc) -> Router { .route("/api/pair/cancel", axum::routing::post(handlers::pair_cancel)); let authenticated_routes = Router::new() + .route("/api/directory-index/policy", axum::routing::get(handlers::directory_policy_status)) + .route( + "/api/directory-index/policy/challenge", + axum::routing::post(handlers::directory_policy_challenge), + ) + .route( + "/api/directory-index/policy/redeem", + axum::routing::post(handlers::directory_policy_redeem), + ) .route("/api/download-intents", axum::routing::post(handlers::create_download_intent)) .route("/api/pair/test", axum::routing::post(handlers::test_pairing)) .route("/api/pair/current", axum::routing::delete(handlers::delete_current_pairing)) @@ -151,7 +192,7 @@ fn build_router(state: Arc) -> Router { ) .route("/api/browse/{drive}/rename", axum::routing::post(handlers::browse_rename)) .route("/api/browse/{drive}/create", axum::routing::post(handlers::browse_create)) - .merge(transfer_routes().with_state::>(())) + .merge(transfer_routes()) .route("/api/browse/{drive}/open", axum::routing::post(handlers::browse_open)) .route( "/api/browse/{drive}/directories", @@ -227,16 +268,23 @@ fn build_router(state: Arc) -> Router { // WebSocket route — uses query-param auth (browser WS API has no custom headers). let ws_routes = Router::new().route("/api/ws", axum::routing::get(handlers::ws_upgrade)); - Router::new() + let router = Router::new() .merge(public_routes) .merge(authenticated_routes) .merge(viewer_routes) .merge(ws_routes) .layer(cors) - .with_state(state) + .with_state(Arc::clone(&state)); + #[cfg(feature = "recovery-verification")] + let router = router.merge(recovery_verification::routes().with_state(state)); + router } -fn transfer_routes() -> Router { +fn transfer_routes() -> Router +where + S: Clone + Send + Sync + 'static, + handlers::DirectoryMutationState: axum::extract::FromRef, +{ Router::new() .route( "/api/browse/{drive}/transfer-attempts/{attempt_id}/cancel", @@ -259,7 +307,10 @@ mod tests { #[tokio::test] async fn transfer_stream_route_is_registered() { + let temporary = tempfile::tempdir().unwrap(); + let indexes = super::directory_manager::LocalDirectoryManager::new(temporary.path().join("private")).unwrap(); let response = transfer_routes() + .with_state(super::handlers::DirectoryMutationState(indexes)) .oneshot( Request::builder() .method("GET") diff --git a/companion/src-tauri/src/server/models.rs b/companion/src-tauri/src/server/models.rs index 186848c5d..bf48612c4 100644 --- a/companion/src-tauri/src/server/models.rs +++ b/companion/src-tauri/src/server/models.rs @@ -495,7 +495,7 @@ pub struct DriveInfo { } /// Classification of a drive's physical/logical type. -#[derive(Debug, Serialize)] +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Default)] #[serde(rename_all = "lowercase")] #[allow(dead_code)] pub enum DriveType { @@ -503,6 +503,7 @@ pub enum DriveType { Removable, Network, Virtual, + #[default] Unknown, } @@ -633,4 +634,14 @@ pub struct DirectorySearchResult { pub total_matches: usize, pub cache_state: String, pub directory_count: usize, + pub search_contract_version: Option, + pub match_ranks: std::collections::HashMap, + pub matched_term_counts: std::collections::HashMap, + pub accepted_revision: Option, + pub coverage: Option, + pub coverage_issue_count: usize, + pub last_verified_at_ms: Option, + pub status_message: Option, + pub monitoring_state: Option, + pub retry_after_ms: Option, } diff --git a/companion/src-tauri/src/server/recovery_verification.rs b/companion/src-tauri/src/server/recovery_verification.rs new file mode 100644 index 000000000..6843c903f --- /dev/null +++ b/companion/src-tauri/src/server/recovery_verification.rs @@ -0,0 +1,83 @@ +//! Fixture-only local API controls, excluded from ordinary and release builds. + +use std::path::PathBuf; +use std::sync::Arc; + +use axum::extract::{Path, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::{routing::post, Json, Router}; +use serde_json::{json, Value}; + +use super::AppState; + +pub(super) const ROOT_ENV: &str = "SAMBEE_RECOVERY_ROOT"; +const TOKEN_ENV: &str = "SAMBEE_RECOVERY_TOKEN"; +const TOKEN_HEADER: &str = "x-recovery-token"; +const DRIVE: &str = "recovery-verification"; +const SHUTDOWN_RESPONSE_DELAY: std::time::Duration = std::time::Duration::from_millis(300); + +fn fixture(headers: &HeaderMap) -> Result { + let token = std::env::var(TOKEN_ENV).map_err(|_| (StatusCode::NOT_FOUND, "Verification is not configured."))?; + if token.is_empty() || headers.get(TOKEN_HEADER).and_then(|value| value.to_str().ok()) != Some(token.as_str()) { + return Err((StatusCode::UNAUTHORIZED, "Verification token is not valid.")); + } + std::env::var_os(ROOT_ENV) + .map(PathBuf::from) + .ok_or((StatusCode::NOT_FOUND, "Verification fixture is not configured.")) +} + +pub(super) fn routes() -> Router> { + Router::new().route("/__verification/{action}", post(control)) +} + +async fn control( + State(state): State>, + Path(action): Path, + headers: HeaderMap, +) -> Result, (StatusCode, String)> { + let root = fixture(&headers).map_err(|(status, message)| (status, message.to_owned()))?; + let failure = |_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Verification operation failed. Inspect the recovery report.".to_owned(), + ) + }; + match action.as_str() { + "search" => { + let response = state + .directory_indexes + .search(DRIVE.into(), root, "reports".into(), false) + .await + .map_err(failure)?; + return Ok(Json(json!(response))); + } + "lose" | "restore" => state + .directory_indexes + .verification_watcher_loss(DRIVE, action == "restore") + .map_err(failure)?, + "subscribe" => state + .watcher + .subscribe(DRIVE, "", &root) + .await + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Fixture subscription failed.".into()))?, + "unsubscribe" => state.watcher.unsubscribe(DRIVE, "").await, + "stop" | "shutdown" => { + if !state.directory_indexes.verification_stop(DRIVE).map_err(failure)? { + return Ok(Json(json!({"stopped": false}))); + } + state.watcher.unsubscribe(DRIVE, "").await; + if action == "shutdown" { + let app = state.app.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(SHUTDOWN_RESPONSE_DELAY).await; + app.exit(0); + }); + } + return Ok(Json(json!({"stopped": true}))); + } + _ => return Err((StatusCode::NOT_FOUND, "Unknown verification action.".into())), + } + Ok(Json( + json!({"ok": true, "subscriptions": state.watcher.verification_subscription_count().await}), + )) +} diff --git a/companion/src-tauri/src/server/watcher.rs b/companion/src-tauri/src/server/watcher.rs index 17f51725c..fd4e44234 100644 --- a/companion/src-tauri/src/server/watcher.rs +++ b/companion/src-tauri/src/server/watcher.rs @@ -152,6 +152,11 @@ pub struct DirectoryWatcher { } impl DirectoryWatcher { + #[cfg(feature = "recovery-verification")] + pub(super) async fn verification_subscription_count(&self) -> usize { + self.entries.lock().await.values().map(|entry| entry.subscriber_count).sum() + } + /// Create a new watcher manager. pub fn new() -> Self { let (tx, _) = broadcast::channel(BROADCAST_CAPACITY); diff --git a/companion/src-tauri/tauri.conf.json b/companion/src-tauri/tauri.conf.json index 7ae4bed93..0f6e03a3e 100644 --- a/companion/src-tauri/tauri.conf.json +++ b/companion/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2-schema/refs/heads/main/tauri.conf.json.schema", "productName": "Sambee Companion", "identifier": "app.sambee.companion", - "version": "2.0.26", + "version": "2.0.27", "build": { "beforeDevCommand": "npm run dev", "devUrl": "http://localhost:1420", diff --git a/companion/src/components/Preferences.tsx b/companion/src/components/Preferences.tsx index 9c8f0e9f5..6e92547f4 100644 --- a/companion/src/components/Preferences.tsx +++ b/companion/src/components/Preferences.tsx @@ -49,6 +49,131 @@ type UpdateActionState = | { kind: "installed"; version: string | null; checkedChannel: CompanionUpdateChannel } | { kind: "error"; message: string; checkedChannel: CompanionUpdateChannel }; +interface NativeDirectoryPolicyStatus { + status: string; + owner_origin: string | null; + effective_revision: number | null; + message: string | null; +} + +function DirectoryPolicyPreferences() { + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const [confirmReset, setConfirmReset] = useState(false); + const [resetting, setResetting] = useState(false); + const cancelRef = useRef(null); + const load = useCallback(async () => { + try { + setStatus((await invoke("get_native_directory_policy_status")) ?? null); + setError(null); + } catch { + setError(translate("preferences.directoryPolicy.unavailable")); + } + }, []); + useEffect(() => { + void load(); + }, [load]); + const reset = async () => { + setResetting(true); + setError(null); + try { + await invoke("reset_native_directory_policy_owner"); + await load(); + setConfirmReset(false); + } catch { + setError(translate("preferences.directoryPolicy.resetFailed")); + } finally { + setResetting(false); + } + }; + return ( +
+ {translate("preferences.directoryPolicy.title")} +
+ + {error ?? + status?.message ?? + (!status + ? translate("preferences.directoryPolicy.loading") + : status.owner_origin + ? translate("preferences.directoryPolicy.active") + : translate("preferences.directoryPolicy.defaults"))} + + {status?.owner_origin && ( +
+
+
{translate("preferences.directoryPolicy.owner")}
+
{status.owner_origin}
+
+
+
{translate("preferences.directoryPolicy.revision")}
+
{status.effective_revision}
+
+
+ )} +
+ + +
+
+ {confirmReset && ( + { + if (!resetting) setConfirmReset(false); + }} + panelClassName="preferences__confirm-panel" + > +

+ {translate("preferences.directoryPolicy.resetTitle")} +

+

{translate("preferences.directoryPolicy.resetBody")}

+ {error && ( + + )} +
+ + +
+
+ )} +
+ ); +} + function getErrorMessage(error: unknown): string { if (error instanceof Error && error.message) { return error.message; @@ -461,6 +586,9 @@ export function Preferences({ onClose }: PreferencesProps) {
+ +
+ {/* ── Localization Sync ── */}
diff --git a/companion/src/components/__tests__/Preferences.test.tsx b/companion/src/components/__tests__/Preferences.test.tsx index ef5772048..fa3be4230 100644 --- a/companion/src/components/__tests__/Preferences.test.tsx +++ b/companion/src/components/__tests__/Preferences.test.tsx @@ -51,6 +51,40 @@ vi.mock("../../lib/updateCheck", () => ({ })); describe("Preferences", () => { + it("resets a policy owner only after a native confirmation with Cancel focused", async () => { + getUserPreferencesMock.mockResolvedValue({ + allowedServers: [], + uploadConflictAction: "ask", + autoStartOnLogin: false, + showNotifications: true, + companionUpdateChannel: "stable", + tempFileRetentionDays: 7, + }); + isAutostartEnabledMock.mockResolvedValue(false); + let owned = true; + invokeMock.mockImplementation(async (command: string) => { + if (command === "get_paired_origins") return []; + if (command === "get_synced_localization") return null; + if (command === "get_native_directory_policy_status") + return { + status: owned ? "applied" : "unowned", + owner_origin: owned ? "https://owner.example" : null, + effective_revision: owned ? 3 : null, + message: null, + }; + if (command === "reset_native_directory_policy_owner") owned = false; + }); + render(); + expect(await screen.findByText("https://owner.example")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Reset Policy Owner" })); + expect(await screen.findByRole("alertdialog")).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole("button", { name: translate("common.actions.cancel") })).toHaveFocus()); + expect(invokeMock).not.toHaveBeenCalledWith("reset_native_directory_policy_owner"); + fireEvent.click(screen.getByRole("button", { name: "Reset Owner" })); + await waitFor(() => expect(invokeMock).toHaveBeenCalledWith("reset_native_directory_policy_owner")); + expect(await screen.findByText("Local default policy is active. No backend owner is approved.")).toBeInTheDocument(); + }); + beforeEach(() => { invokeMock.mockReset(); listenMock.mockReset(); diff --git a/companion/src/i18n/resources.ts b/companion/src/i18n/resources.ts index faf26c747..45774bcd7 100644 --- a/companion/src/i18n/resources.ts +++ b/companion/src/i18n/resources.ts @@ -157,6 +157,23 @@ export const EN_TRANSLATIONS = { pairedBrowsersHint: "These trusted browser origins can access local drives through this companion. Removing one forces that origin to request approval again.", pairedBrowsersEmpty: "No browser origins are currently trusted by this companion.", + directoryPolicy: { + title: "Directory-Index Policy", + loading: "Directory policy status is loading.", + unavailable: "Directory policy is unavailable. Wait briefly and refresh its status, or restart the companion.", + resetFailed: "Policy owner could not be reset. Check private configuration storage and retry.", + active: "Approved backend policy is active.", + defaults: "Local default policy is active. No backend owner is approved.", + owner: "Approved Owner", + revision: "Effective Revision", + refresh: "Refresh Policy Status", + reset: "Reset Policy Owner", + resetTitle: "Reset Directory-Index Policy Owner?", + resetBody: + "Remove the approved backend owner and restore local policy defaults? Existing directory indexes are retained. Another backend will need local approval.", + resetting: "Resetting Policy Owner", + confirmReset: "Reset Owner", + }, localizationStatusHint: "Shows the last localization synchronized from a trusted Sambee browser origin.", localizationStatus: { syncedBadge: "Synced from browser", diff --git a/config.example.toml b/config.example.toml index b3ac1e02a..15aa55ce5 100644 --- a/config.example.toml +++ b/config.example.toml @@ -71,18 +71,18 @@ username = "admin" # tracing_username_regex = "" [directory_cache] -# Directory cache persistence settings for instant directory navigation. -# The cache stores an index of all directories on each SMB share so that -# search results are available immediately when the backend restarts. +# Private backend directory-index storage. SQLite catalogues are session-bound; +# source revalidation and rebuild are required after a backend restart. +# Resource policies are managed in Settings > Advanced, not in this section. # Location on disk (relative to data directory, or absolute path). # Default: "directory_cache" (i.e. data/directory_cache/) # location = "directory_cache" -# Minimum seconds between disk writes (coalesces rapid changes). +# Legacy snapshot setting; does not control SQLite writes. # coalesce_interval_seconds = 30 -# Ignore cached snapshots older than this many minutes (default: 43200 = 30 days). +# Legacy snapshot setting; does not control SQLite retention. # max_staleness_minutes = 43200 [preprocessors.imagemagick] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7a6b8f3d3..e604be59f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "sambee-frontend", - "version": "2.0.26", + "version": "2.0.27", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sambee-frontend", - "version": "2.0.26", + "version": "2.0.27", "hasInstallScript": true, "dependencies": { "@codemirror/autocomplete": "^6.20.4", diff --git a/frontend/package.json b/frontend/package.json index c10b880b5..b7963005c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "sambee-frontend", - "version": "2.0.26", + "version": "2.0.27", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/FileBrowser/QuickBarResultRow.tsx b/frontend/src/components/FileBrowser/QuickBarResultRow.tsx index 562c8c171..6abcca2f2 100644 --- a/frontend/src/components/FileBrowser/QuickBarResultRow.tsx +++ b/frontend/src/components/FileBrowser/QuickBarResultRow.tsx @@ -21,6 +21,7 @@ function getResultIcon(icon: SearchResultIcon): React.ReactElement { return ; case "directory": return ; + case "recent-directory": case "recent-file": return ; case "file": @@ -28,20 +29,30 @@ function getResultIcon(icon: SearchResultIcon): React.ReactElement { } } -function renderHighlightedText(text: string, highlight?: SearchTextHighlight): React.ReactNode { - if (!highlight || highlight.start < 0 || highlight.end <= highlight.start || highlight.end > text.length) { - return text; - } - - return ( - <> - {text.slice(0, highlight.start)} - - {text.slice(highlight.start, highlight.end)} +function renderHighlightedText(text: string, highlight?: SearchTextHighlight | SearchTextHighlight[]): React.ReactNode { + if (!highlight) return text; + const ranges = Array.isArray(highlight) ? highlight : [highlight]; + const pieces: React.ReactNode[] = []; + let offset = 0; + for (const range of ranges) { + if ( + !Number.isInteger(range.start) || + !Number.isInteger(range.end) || + range.start < offset || + range.end <= range.start || + range.end > text.length + ) + continue; + pieces.push(text.slice(offset, range.start)); + pieces.push( + + {text.slice(range.start, range.end)} - {text.slice(highlight.end)} - - ); + ); + offset = range.end; + } + pieces.push(text.slice(offset)); + return <>{pieces}; } /** Returns the virtualized height for a structured quick-bar result. */ @@ -50,7 +61,7 @@ export function getQuickBarResultRowHeight(result: SearchResult): number { } export function QuickBarResultRow({ result, useCompactLayout = false }: { result: SearchResultItem; useCompactLayout?: boolean }) { - const isDirectory = result.icon === "directory"; + const isDirectory = result.icon === "directory" || result.icon === "recent-directory"; return ( diff --git a/frontend/src/components/FileBrowser/UnifiedSearchBar.tsx b/frontend/src/components/FileBrowser/UnifiedSearchBar.tsx index 42eb7729b..194e7b343 100644 --- a/frontend/src/components/FileBrowser/UnifiedSearchBar.tsx +++ b/frontend/src/components/FileBrowser/UnifiedSearchBar.tsx @@ -159,6 +159,8 @@ export interface UnifiedSearchBarModeOption { onSelect: () => void; } +const MIN_BACKGROUND_REFRESH_MS = 500; + // ============================================================================ // Component // ============================================================================ @@ -210,6 +212,12 @@ export function UnifiedSearchBar({ const anchorRef = useRef(null); const abortControllerRef = useRef(null); const debounceTimerRef = useRef | null>(null); + const refreshTimerRef = useRef | null>(null); + const quietRequestRef = useRef(false); + const acceptedQueryRef = useRef(null); + const executeSearchRef = useRef<(value: string, openDropdown: boolean, quiet: boolean) => Promise>(async () => {}); + const dropdownOpenRef = useRef(false); + dropdownOpenRef.current = isDropdownOpen && !disableDropdown && !suppressDropdown; const pendingQueryRef = useRef(""); const providerRef = useRef(provider); const internalInputRef = useRef(null); @@ -218,6 +226,9 @@ export function UnifiedSearchBar({ const lastActivationTokenRef = useRef(activationToken); const lastRefreshTokenRef = useRef(refreshToken); const wasDropdownSuppressedRef = useRef(suppressDropdown); + const queryGenerationRef = useRef(0); + const selectedValueRef = useRef(undefined); + selectedValueRef.current = results[selectedIndex]?.value; // Always keep providerRef current so stable callbacks use the latest provider providerRef.current = provider; @@ -227,6 +238,14 @@ export function UnifiedSearchBar({ const setQuery = useCallback( (value: string) => { + queryGenerationRef.current += 1; + acceptedQueryRef.current = null; + if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); + refreshTimerRef.current = null; + if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + abortControllerRef.current?.abort(); + setIsLoading(false); providerRef.current.onQueryChange?.(value); onQueryValueChange?.(value); @@ -450,7 +469,17 @@ export function UnifiedSearchBar({ // executeSearch // const executeSearch = useCallback( - async (searchQuery: string, openDropdown = true) => { + async (searchQuery: string, openDropdown = true, quiet = false) => { + if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); + refreshTimerRef.current = null; + if ( + quiet && + (document.visibilityState === "hidden" || + !dropdownOpenRef.current || + acceptedQueryRef.current !== searchQuery || + providerRef.current.getRefreshIntervalMs?.() == null) + ) + return; // Cancel any in-flight request if (abortControllerRef.current) { abortControllerRef.current.abort(); @@ -458,30 +487,54 @@ export function UnifiedSearchBar({ const controller = new AbortController(); abortControllerRef.current = controller; + quietRequestRef.current = quiet; + const generation = queryGenerationRef.current; + const activeProvider = providerRef.current; + const isCurrent = () => + !controller.signal.aborted && + generation === queryGenerationRef.current && + activeProvider.id === providerRef.current.id && + activeProvider.sourceKey === providerRef.current.sourceKey; + const publishResults = (updatedResults: SearchResult[]) => { + if (!isCurrent()) return; + const previousValue = selectedValueRef.current; + const previousIndex = updatedResults.findIndex((result) => result.kind === "result" && result.value === previousValue); + const firstIndex = updatedResults.findIndex((result) => result.kind === "result"); + setResults(updatedResults); + setSelectedIndex(Math.max(previousIndex >= 0 ? previousIndex : firstIndex, 0)); + setHasSearched(true); + if (openDropdown) setIsDropdownOpen(true); + }; try { - setIsSearchPending(false); - setIsLoading(true); - setSearchError(false); - setRemoveError(false); - const fetchedResults = await providerRef.current.fetchResults(searchQuery, controller.signal); - - if (!controller.signal.aborted) { - setResults(fetchedResults); - setSelectedIndex( - Math.max( - fetchedResults.findIndex((result) => result.kind !== "group-header"), - 0 - ) - ); - setHasSearched(true); - if (openDropdown) { - setIsDropdownOpen(true); + if (!quiet) { + acceptedQueryRef.current = null; + setIsSearchPending(false); + setIsLoading(true); + setSearchError(false); + setRemoveError(false); + } + const fetchedResults = await activeProvider.fetchResults(searchQuery, controller.signal, publishResults); + publishResults(fetchedResults); + if (isCurrent()) { + acceptedQueryRef.current = searchQuery; + const interval = providerRef.current.getRefreshIntervalMs?.(); + if ( + interval != null && + Number.isFinite(interval) && + interval >= MIN_BACKGROUND_REFRESH_MS && + document.visibilityState !== "hidden" + ) { + refreshTimerRef.current = setTimeout(() => { + refreshTimerRef.current = null; + if (isCurrent()) void executeSearchRef.current(searchQuery, false, true); + }, interval); } } } catch { - if (!controller.signal.aborted) { - setResults([]); + if (isCurrent()) { + acceptedQueryRef.current = null; + if (!quiet) setResults([]); setHasSearched(true); setSearchError(true); if (openDropdown) { @@ -489,7 +542,7 @@ export function UnifiedSearchBar({ } } } finally { - if (!controller.signal.aborted) { + if (isCurrent()) { setIsLoading(false); } } @@ -497,6 +550,7 @@ export function UnifiedSearchBar({ // providerRef is stable — no dependency on provider object identity [] ); + executeSearchRef.current = executeSearch; // ── Input handling ───────────────────────────────────────────────────── @@ -623,6 +677,23 @@ export function UnifiedSearchBar({ return "associated-viewer"; }, []); + const dismissSearch = useCallback(() => { + queryGenerationRef.current += 1; + acceptedQueryRef.current = null; + if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); + refreshTimerRef.current = null; + abortControllerRef.current?.abort(); + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + setIsLoading(false); + setIsSearchPending(false); + setIsDropdownOpen(false); + activatedRef.current = false; + providerRef.current.onDeactivate?.(); + }, []); + // ── Keyboard navigation ──────────────────────────────────────────────── // @@ -708,7 +779,7 @@ export function UnifiedSearchBar({ } if (isDropdownOpen) { // First Escape: close the dropdown - setIsDropdownOpen(false); + dismissSearch(); } else if (query) { // Second Escape: clear the query setQuery(""); @@ -723,13 +794,14 @@ export function UnifiedSearchBar({ case "Tab": if (isDropdownOpen) { - setIsDropdownOpen(false); + dismissSearch(); } break; } }, [ disableDropdown, + dismissSearch, findSelectableResultIndex, getSelectionAction, handleSelect, @@ -804,14 +876,15 @@ export function UnifiedSearchBar({ // handleClickAway // const handleClickAway = useCallback(() => { - setIsDropdownOpen(false); - }, []); + dismissSearch(); + }, [dismissSearch]); // ── Re-open on typing after click-away ───────────────────────────────── // // handleInputFocus // const handleInputFocus = useCallback(() => { + const newlyActivated = !activatedRef.current; setIsFocused(true); handleFocus(); @@ -828,8 +901,11 @@ export function UnifiedSearchBar({ return; } - if (!hasSearched && query.length === 0 && effectiveMinQueryLength === 0) { - void executeSearch(""); + if ( + (!hasSearched && query.length === 0 && effectiveMinQueryLength === 0) || + (newlyActivated && hasSearched && query.length >= effectiveMinQueryLength) + ) { + void executeSearch(query); } // Re-open dropdown if there are results to show @@ -871,6 +947,7 @@ export function UnifiedSearchBar({ if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); } + if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); providerRef.current.onDeactivate?.(); }; }, []); @@ -879,7 +956,7 @@ export function UnifiedSearchBar({ // biome-ignore lint/correctness/useExhaustiveDependencies: Reset only on explicit provider identity / activation changes useEffect(() => { const previousProvider = lastResetProviderRef.current; - const providerChanged = previousProvider.id !== provider.id; + const providerChanged = previousProvider.id !== provider.id || previousProvider.sourceKey !== provider.sourceKey; const activationChanged = lastActivationTokenRef.current !== activationToken; if (!providerChanged && !activationChanged) { @@ -887,6 +964,10 @@ export function UnifiedSearchBar({ } previousProvider.onDeactivate?.(); + queryGenerationRef.current += 1; + acceptedQueryRef.current = null; + if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); + refreshTimerRef.current = null; if (abortControllerRef.current) { abortControllerRef.current.abort(); @@ -949,9 +1030,33 @@ export function UnifiedSearchBar({ getEffectiveMinQueryLength, isControlledQuery, provider.id, + provider.sourceKey, query, ]); + useEffect(() => { + const stopQuietRefresh = () => { + if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); + refreshTimerRef.current = null; + if (quietRequestRef.current) abortControllerRef.current?.abort(); + }; + const onVisibilityChange = () => { + if (document.visibilityState === "hidden") { + stopQuietRefresh(); + abortControllerRef.current?.abort(); + if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + setIsLoading(false); + setIsSearchPending(false); + } else if (dropdownOpenRef.current && acceptedQueryRef.current === query && providerRef.current.getRefreshIntervalMs?.() != null) { + void executeSearchRef.current(query, false, true); + } + }; + if (!isDropdownOpen || disableDropdown || suppressDropdown) stopQuietRefresh(); + document.addEventListener("visibilitychange", onVisibilityChange); + return () => document.removeEventListener("visibilitychange", onVisibilityChange); + }, [disableDropdown, isDropdownOpen, query, suppressDropdown]); + useEffect(() => { if (lastRefreshTokenRef.current === refreshToken) { return; @@ -959,6 +1064,7 @@ export function UnifiedSearchBar({ lastRefreshTokenRef.current = refreshToken; if (!disableDropdown && !suppressDropdown && query.length >= getEffectiveMinQueryLength(query)) { + providerRef.current.invalidateResults?.(); void executeSearch(query, false); } }, [disableDropdown, executeSearch, getEffectiveMinQueryLength, query, refreshToken, suppressDropdown]); diff --git a/frontend/src/components/FileBrowser/__tests__/UnifiedSearchBar.test.tsx b/frontend/src/components/FileBrowser/__tests__/UnifiedSearchBar.test.tsx index 36737f919..c3d9748b0 100644 --- a/frontend/src/components/FileBrowser/__tests__/UnifiedSearchBar.test.tsx +++ b/frontend/src/components/FileBrowser/__tests__/UnifiedSearchBar.test.tsx @@ -1,13 +1,22 @@ -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; import { setLocale, translate } from "../../../i18n"; +import { browserHistoryService } from "../../../services/browserHistoryService"; import { SambeeThemeProvider } from "../../../theme/ThemeContext"; import { getQuickBarResultRowHeight, QUICK_BAR_RESULT_GROUP_HEADER_HEIGHT, QUICK_BAR_RESULT_ITEM_HEIGHT } from "../QuickBarResultRow"; -import type { SearchProvider } from "../search/types"; +import { SEARCH_CONTRACT_VERSION } from "../search/recentHistory"; +import type { SearchProvider, SearchResultsUpdate } from "../search/types"; +import { useDirectorySearchProvider } from "../search/useDirectorySearchProvider"; import { UnifiedSearchBar } from "../UnifiedSearchBar"; const virtualizerSizeCache = new Map(); +const INPUT_DEBOUNCE_MS = 160; +const QUIET_REFRESH_INTERVAL_MS = 500; +const MOCK_HISTORY_RESPONSE_MS = 40; +const MOCK_INDEX_RESPONSE_MS = 80; +const VISIBLE_RESULTS_TARGET_MS = 300; +const INCREMENTAL_KEY_INTERVAL_MS = 50; vi.mock("@tanstack/react-virtual", () => ({ useVirtualizer: ({ @@ -268,6 +277,360 @@ function renderWithProvider(component: React.ReactElement) { } describe("UnifiedSearchBar", () => { + it.each(["connection-1", "local-C"])("completes activation, recovery, source switching, and dismissal for %s", async (connectionId) => { + vi.useFakeTimers(); + const retryMs = 2000; + let phase: "paused" | "failed" | "healthy" = "paused"; + const history = { + ...browserHistoryService, + searchRecentDirectories: vi.fn().mockResolvedValue({ result_limit: 10, results: [] }), + searchDirectories: vi.fn(async () => { + if (phase === "failed") throw { response: { status: 503, data: { detail: { recoverable: true, retry_after_ms: retryMs } } } }; + return { + results: phase === "healthy" ? ["Recovered Reports"] : [], + total_matches: phase === "healthy" ? 1 : 0, + directory_count: 1, + cache_state: "ready" as const, + monitoring_state: phase === "healthy" ? ("healthy" as const) : ("unavailable" as const), + retry_after_ms: phase === "healthy" ? null : retryMs, + search_contract_version: SEARCH_CONTRACT_VERSION, + }; + }), + }; + function RecoveryHarness({ source }: { source: string }) { + const provider = useDirectorySearchProvider(source, vi.fn(), { history }); + return ; + } + try { + const { rerender } = renderWithProvider(); + const input = screen.getByRole("textbox"); + await act(async () => input.focus()); + fireEvent.change(input, { target: { value: "reports" } }); + await act(() => vi.advanceTimersByTimeAsync(INPUT_DEBOUNCE_MS)); + expect(history.searchDirectories).toHaveBeenCalledWith(connectionId, "", expect.anything()); + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + phase = "failed"; + await act(() => vi.advanceTimersByTimeAsync(retryMs)); + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + phase = "healthy"; + await act(() => vi.advanceTimersByTimeAsync(retryMs)); + expect(screen.getByRole("option", { name: /Recovered Reports/ })).toBeInTheDocument(); + phase = "paused"; + const replacement = connectionId === "connection-1" ? "local-C" : "connection-1"; + rerender( + + + + ); + await act(() => vi.advanceTimersByTimeAsync(INPUT_DEBOUNCE_MS)); + expect(history.searchDirectories).toHaveBeenCalledWith(replacement, expect.any(String), expect.anything()); + expect(screen.queryByRole("option", { name: /Recovered Reports/ })).not.toBeInTheDocument(); + fireEvent.keyDown(input, { key: "Escape" }); + const calls = history.searchDirectories.mock.calls.length; + await act(() => vi.advanceTimersByTimeAsync(retryMs * 3)); + expect(history.searchDirectories).toHaveBeenCalledTimes(calls); + } finally { + cleanup(); + vi.useRealTimers(); + } + }); + + it.each(["pasted", "incremental"])("publishes selectable merged rows within the mocked %s-query timing budget", async (inputMode) => { + vi.useFakeTimers(); + try { + const onNavigateDirectory = vi.fn(); + const history = { + ...browserHistoryService, + searchRecentDirectories: vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, MOCK_HISTORY_RESPONSE_MS)); + return { + result_limit: 10, + results: [{ id: "recent", connection_id: "connection-1", path: "Reports", last_visited_at: "2026-01-01T00:00:00Z" }], + }; + }), + searchDirectories: vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, MOCK_INDEX_RESPONSE_MS)); + return { + results: ["Reports", "Quarterly Reports"], + total_matches: 2, + directory_count: 100_000, + cache_state: "ready" as const, + search_contract_version: SEARCH_CONTRACT_VERSION, + }; + }), + }; + function DirectoryTimingHarness() { + const provider = useDirectorySearchProvider("connection-1", vi.fn(), { history, onNavigateDirectory }); + return ; + } + renderWithProvider(); + const input = screen.getByRole("textbox"); + if (inputMode === "incremental") { + for (const prefix of ["r", "re", "rep"]) { + fireEvent.change(input, { target: { value: prefix } }); + await act(() => vi.advanceTimersByTimeAsync(INCREMENTAL_KEY_INTERVAL_MS)); + } + } + const started = Date.now(); + fireEvent.change(input, { target: { value: "reports" } }); + await act(() => vi.advanceTimersByTimeAsync(INPUT_DEBOUNCE_MS - 1)); + expect(history.searchDirectories).not.toHaveBeenCalled(); + expect(history.searchRecentDirectories).not.toHaveBeenCalled(); + await act(() => vi.advanceTimersByTimeAsync(1 + MOCK_HISTORY_RESPONSE_MS)); + expect(screen.getAllByRole("option")).toHaveLength(1); + expect(Date.now() - started).toBe(INPUT_DEBOUNCE_MS + MOCK_HISTORY_RESPONSE_MS); + await act(() => vi.advanceTimersByTimeAsync(MOCK_INDEX_RESPONSE_MS - MOCK_HISTORY_RESPONSE_MS)); + expect(screen.getAllByRole("option")).toHaveLength(2); + expect(screen.getByRole("option", { name: /Quarterly Reports/ })).toBeInTheDocument(); + expect(Date.now() - started).toBeLessThanOrEqual(VISIBLE_RESULTS_TARGET_MS); + expect(history.searchDirectories).toHaveBeenCalledTimes(1); + expect(history.searchRecentDirectories).toHaveBeenCalledTimes(1); + fireEvent.keyDown(input, { key: "Enter" }); + expect(onNavigateDirectory).toHaveBeenCalledWith("connection-1", "Reports", "recent"); + } finally { + cleanup(); + vi.useRealTimers(); + } + }); + + it("does not overlap quiet refreshes and gives new input priority over a blocked refresh", async () => { + vi.useFakeTimers(); + try { + const stableRows = [{ kind: "result" as const, id: "stable", value: "stable", icon: "directory" as const, primaryText: "Stable" }]; + const freshRows = stableRows.map((row) => ({ ...row, id: "fresh", value: "fresh", primaryText: "Fresh" })); + let finishRefresh: ((rows: typeof stableRows) => void) | undefined; + const fetchResults = vi + .fn() + .mockResolvedValueOnce(stableRows) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishRefresh = resolve; + }) + ) + .mockResolvedValue(freshRows); + renderWithProvider( + QUIET_REFRESH_INTERVAL_MS, + }} + /> + ); + const input = screen.getByPlaceholderText("Search"); + act(() => input.focus()); + fireEvent.change(input, { target: { value: "stable" } }); + await act(() => vi.advanceTimersByTimeAsync(INPUT_DEBOUNCE_MS)); + expect(fetchResults).toHaveBeenCalledTimes(1); + await act(() => vi.advanceTimersByTimeAsync(QUIET_REFRESH_INTERVAL_MS - 1)); + expect(fetchResults).toHaveBeenCalledTimes(1); + await act(() => vi.advanceTimersByTimeAsync(1)); + expect(fetchResults).toHaveBeenCalledTimes(2); + await act(() => vi.advanceTimersByTimeAsync(QUIET_REFRESH_INTERVAL_MS * 3)); + expect(fetchResults).toHaveBeenCalledTimes(2); + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + expect(screen.getByText("Stable")).toBeInTheDocument(); + const refreshSignal = fetchResults.mock.calls[1]?.[1]; + fireEvent.change(input, { target: { value: "fresh" } }); + expect(refreshSignal?.aborted).toBe(true); + await act(() => vi.advanceTimersByTimeAsync(INPUT_DEBOUNCE_MS - 1)); + expect(fetchResults).toHaveBeenCalledTimes(2); + await act(() => vi.advanceTimersByTimeAsync(1)); + expect(fetchResults).toHaveBeenCalledTimes(3); + expect(fetchResults.mock.lastCall?.[0]).toBe("fresh"); + expect(screen.getByText("Fresh")).toBeInTheDocument(); + await act(async () => finishRefresh?.(stableRows)); + expect(screen.queryByText("Stable")).not.toBeInTheDocument(); + expect(screen.getByText("Fresh")).toBeInTheDocument(); + expect(document.activeElement).toBe(input); + } finally { + cleanup(); + vi.useRealTimers(); + } + }); + + it("refreshes without a foreground spinner and stops after dismissal", async () => { + const fetchResults = vi.fn(async () => [ + { kind: "result" as const, id: "stable", value: "stable", icon: "directory" as const, primaryText: "Stable" }, + ]); + render( + + 500 }} /> + + ); + const input = screen.getByPlaceholderText("Search"); + fireEvent.change(input, { target: { value: "stable" } }); + await waitFor(() => expect(fetchResults).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(fetchResults).toHaveBeenCalledTimes(2), { timeout: 1200 }); + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + expect(screen.getByText("Stable")).toBeInTheDocument(); + fireEvent.keyDown(input, { key: "Escape" }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 550)); + }); + expect(fetchResults).toHaveBeenCalledTimes(2); + }); + + it.each(["", "reports"])("restarts recovery when a dismissed query '%s' is focused again", async (query) => { + vi.useFakeTimers(); + try { + const fetchResults = vi + .fn() + .mockResolvedValue([{ kind: "result", id: "stable", value: "stable", icon: "directory", primaryText: "Stable" }]); + const onActivate = vi.fn(); + renderWithProvider( + QUIET_REFRESH_INTERVAL_MS, + }} + /> + ); + const input = screen.getByPlaceholderText("Search"); + await act(async () => input.focus()); + if (query) { + fireEvent.change(input, { target: { value: query } }); + await act(() => vi.advanceTimersByTimeAsync(INPUT_DEBOUNCE_MS)); + } + expect(fetchResults).toHaveBeenCalledTimes(1); + fireEvent.keyDown(input, { key: "Escape" }); + await act(() => vi.advanceTimersByTimeAsync(QUIET_REFRESH_INTERVAL_MS * 2)); + expect(fetchResults).toHaveBeenCalledTimes(1); + act(() => input.blur()); + await act(async () => input.focus()); + expect(onActivate).toHaveBeenCalledTimes(2); + expect(fetchResults).toHaveBeenCalledTimes(2); + expect(fetchResults.mock.lastCall?.[0]).toBe(query); + await act(() => vi.advanceTimersByTimeAsync(QUIET_REFRESH_INTERVAL_MS)); + expect(fetchResults).toHaveBeenCalledTimes(3); + } finally { + cleanup(); + vi.useRealTimers(); + } + }); + + it("pauses hidden-tab work, resumes once, and cancels refresh on unmount", async () => { + vi.useFakeTimers(); + const visibility = vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible"); + try { + const rows = [{ kind: "result" as const, id: "stable", value: "stable", icon: "directory" as const, primaryText: "Stable" }]; + let finishHiddenRequest: ((results: typeof rows) => void) | undefined; + const fetchResults = vi + .fn() + .mockResolvedValueOnce(rows) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishHiddenRequest = resolve; + }) + ) + .mockResolvedValue(rows); + const { unmount } = renderWithProvider( + QUIET_REFRESH_INTERVAL_MS }} /> + ); + const input = screen.getByPlaceholderText("Search"); + fireEvent.change(input, { target: { value: "stable" } }); + await act(() => vi.advanceTimersByTimeAsync(0)); + await act(() => vi.advanceTimersByTimeAsync(QUIET_REFRESH_INTERVAL_MS)); + expect(fetchResults).toHaveBeenCalledTimes(2); + visibility.mockReturnValue("hidden"); + act(() => document.dispatchEvent(new Event("visibilitychange"))); + expect(fetchResults.mock.calls[1]?.[1].aborted).toBe(true); + await act(() => vi.advanceTimersByTimeAsync(QUIET_REFRESH_INTERVAL_MS * 3)); + expect(fetchResults).toHaveBeenCalledTimes(2); + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + visibility.mockReturnValue("visible"); + await act(async () => document.dispatchEvent(new Event("visibilitychange"))); + expect(fetchResults).toHaveBeenCalledTimes(3); + await act(async () => finishHiddenRequest?.(rows.map((row) => ({ ...row, primaryText: "Obsolete" })))); + expect(screen.queryByText("Obsolete")).not.toBeInTheDocument(); + expect(screen.getByText("Stable")).toBeInTheDocument(); + unmount(); + await act(() => vi.advanceTimersByTimeAsync(QUIET_REFRESH_INTERVAL_MS * 3)); + expect(fetchResults).toHaveBeenCalledTimes(3); + } finally { + cleanup(); + visibility.mockRestore(); + vi.useRealTimers(); + } + }); + + it("rejects old-source publications and cancels in-flight work on source replacement", async () => { + vi.useFakeTimers(); + try { + const oldRows = [{ kind: "result" as const, id: "old", value: "old", icon: "directory" as const, primaryText: "Old source" }]; + const newRows = oldRows.map((row) => ({ ...row, id: "new", value: "new", primaryText: "New source" })); + let finishOld: ((rows: typeof oldRows) => void) | undefined; + let publishOld: SearchResultsUpdate | undefined; + const onDeactivate = vi.fn(); + const oldFetch = vi.fn().mockImplementation( + (_query, _signal, onUpdate) => + new Promise((resolve) => { + publishOld = onUpdate; + finishOld = resolve; + }) + ); + const oldProvider = { ...testProvider, sourceKey: "old-source", minQueryLength: 1, fetchResults: oldFetch, onDeactivate }; + const newFetch = vi.fn().mockResolvedValue(newRows); + const newProvider = { ...oldProvider, sourceKey: "new-source", fetchResults: newFetch }; + const { rerender } = renderWithProvider(); + const input = screen.getByPlaceholderText("Search"); + act(() => input.focus()); + fireEvent.change(input, { target: { value: "old" } }); + await act(() => vi.advanceTimersByTimeAsync(0)); + expect(oldFetch).toHaveBeenCalledTimes(1); + rerender( + + + + ); + expect(oldFetch.mock.calls[0]?.[1].aborted).toBe(true); + expect(onDeactivate).toHaveBeenCalledTimes(1); + expect(input).toHaveValue(""); + fireEvent.change(input, { target: { value: "new" } }); + await act(() => vi.advanceTimersByTimeAsync(0)); + expect(screen.getByText("New source")).toBeInTheDocument(); + await act(async () => { + publishOld?.(oldRows); + finishOld?.(oldRows); + }); + expect(screen.queryByText("Old source")).not.toBeInTheDocument(); + expect(screen.getByText("New source")).toBeInTheDocument(); + expect(document.activeElement).toBe(input); + await act(() => vi.advanceTimersByTimeAsync(QUIET_REFRESH_INTERVAL_MS * 3)); + expect(oldFetch).toHaveBeenCalledTimes(1); + expect(newFetch).toHaveBeenCalledTimes(1); + } finally { + cleanup(); + vi.useRealTimers(); + } + }); + + it("keeps accepted rows and stops refresh after a quiet request fails", async () => { + const fetchResults = vi + .fn() + .mockResolvedValueOnce([{ kind: "result", id: "stable", value: "stable", icon: "directory", primaryText: "Stable" }]) + .mockRejectedValue(new Error("offline")); + render( + + 500 }} /> + + ); + fireEvent.change(screen.getByPlaceholderText("Search"), { target: { value: "stable" } }); + await waitFor(() => expect(fetchResults).toHaveBeenCalledTimes(2), { timeout: 1200 }); + expect(screen.getByText("Stable")).toBeInTheDocument(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 550)); + }); + expect(fetchResults).toHaveBeenCalledTimes(2); + }); + afterEach(async () => { cleanup(); virtualizerSizeCache.clear(); @@ -326,11 +689,66 @@ describe("UnifiedSearchBar", () => { await user.click(searchInput); await waitFor(() => { - expect(fetchResults).toHaveBeenCalledWith("", expect.any(AbortSignal)); + expect(fetchResults).toHaveBeenCalledWith("", expect.any(AbortSignal), expect.any(Function)); }); expect(screen.queryByText("Loading history")).not.toBeInTheDocument(); }); + it("publishes selectable rows before the final response and rejects old-query updates", async () => { + const user = userEvent.setup(); + const updates = new Map(); + const provider: SearchProvider = { + ...testProvider, + fetchResults: async (query, _signal, onUpdate) => { + if (onUpdate) updates.set(query, onUpdate); + return new Promise(() => undefined); + }, + }; + renderWithProvider(); + const input = screen.getByRole("textbox"); + await user.click(input); + await user.type(input, "a"); + await waitFor(() => expect(updates.has("a")).toBe(true)); + act(() => + updates.get("a")?.([{ kind: "result", id: "history", value: "history", icon: "recent-directory", primaryText: "Available history" }]) + ); + expect(await screen.findByRole("option", { name: "Available history" })).toBeInTheDocument(); + await user.type(input, "b"); + act(() => updates.get("a")?.([{ kind: "result", id: "stale", value: "stale", icon: "directory", primaryText: "Stale result" }])); + expect(screen.queryByRole("option", { name: "Stale result" })).not.toBeInTheDocument(); + }); + + it("preserves the selected value when a complete-array update inserts a row", async () => { + const user = userEvent.setup(); + let update: SearchResultsUpdate | undefined; + const onSelect = vi.fn(); + const provider: SearchProvider = { + ...testProvider, + onSelect, + fetchResults: async (_query, _signal, onUpdate) => { + update = onUpdate; + return new Promise(() => undefined); + }, + }; + const selected = { + kind: "result" as const, + id: "history", + value: "history", + icon: "recent-directory" as const, + primaryText: "History", + }; + renderWithProvider(); + const input = screen.getByRole("textbox"); + await user.click(input); + await user.type(input, "a"); + await waitFor(() => expect(update).toBeTypeOf("function")); + act(() => update?.([selected])); + await screen.findByRole("option", { name: "History" }); + act(() => update?.([{ kind: "result", id: "new", value: "new", icon: "directory", primaryText: "New directory" }, selected])); + await user.keyboard("{Enter}"); + expect(onSelect).toHaveBeenCalledWith("history", "associated-viewer"); + }); + it("hides shortcut hints without hiding footer information", async () => { const user = userEvent.setup(); diff --git a/frontend/src/components/FileBrowser/search/__tests__/searchContract.test.ts b/frontend/src/components/FileBrowser/search/__tests__/searchContract.test.ts new file mode 100644 index 000000000..3fb7da3da --- /dev/null +++ b/frontend/src/components/FileBrowser/search/__tests__/searchContract.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import contract from "../../../../../../shared/directory-index/contract.json"; +import fixtures from "../../../../../../shared/directory-index/search-fixtures.json"; +import { + findSearchHighlights, + getSearchRelevance, + MAX_SEARCH_QUERY_BYTES, + MAX_SEARCH_TERMS, + normalizeSearchText, + rankHistorySearchResults, + searchTerms, +} from "../recentHistory"; +import type { SearchResult } from "../types"; + +describe("shared search contract", () => { + it("uses code-point ordering for terms and deterministic result ties", () => { + expect(searchTerms("😀 \ue000")).toEqual(["\ue000", "😀"]); + const rows: SearchResult[] = ["😀", "\ue000"].map((text) => ({ + kind: "result", + id: text, + value: text, + icon: "directory", + primaryText: text, + matchedTermCount: 1, + matchRank: 3, + })); + expect(rankHistorySearchResults(rows, "test").map((row) => row.value)).toEqual(["\ue000", "😀"]); + }); + it("uses language-neutral normalization and term fixtures", () => { + for (const fixture of fixtures.normalization) expect(normalizeSearchText(fixture.input)).toBe(fixture.expected); + for (const fixture of fixtures.queries) expect(searchTerms(fixture.query)).toEqual([...fixture.terms].sort()); + }); + + it("preserves shared any-term inclusion, unique counts, and full-path ordering", () => { + for (const fixture of fixtures.searches) { + const terms = searchTerms(fixture.query); + const rows = fixtures.paths + .filter((path) => fixture.include_dot || !path.split("/").some((part) => part.startsWith("."))) + .map((path) => ({ + kind: "result" as const, + id: path, + value: path, + icon: "directory" as const, + primaryText: path, + sortPath: path, + depth: path.split("/").length, + ...getSearchRelevance(path, terms, path.split("/").at(-1)), + })) + .filter((row) => row.matchedTermCount > 0); + expect(rows).toHaveLength(fixture.count); + expect(rankHistorySearchResults(rows, fixture.query).map((row) => row.value)).toEqual(fixture.results); + } + }); + + it("uses the shared byte and term limits for ASCII and complete Unicode characters", () => { + expect(MAX_SEARCH_QUERY_BYTES).toBe(contract.limits.max_query_bytes); + expect(MAX_SEARCH_TERMS).toBe(contract.limits.max_query_terms); + expect(searchTerms("a".repeat(MAX_SEARCH_QUERY_BYTES))).toHaveLength(1); + expect(searchTerms("😀".repeat(MAX_SEARCH_QUERY_BYTES / 4))).toHaveLength(1); + expect(() => searchTerms("😀".repeat(MAX_SEARCH_QUERY_BYTES / 4 + 1))).toThrow("UTF-8 bytes"); + expect(searchTerms(Array.from({ length: MAX_SEARCH_TERMS }, (_, index) => `term${index}`).join(" "))).toHaveLength(MAX_SEARCH_TERMS); + }); + + it("maps case-fold expansion, combining marks, overlaps, and separate terms to original text", () => { + expect(findSearchHighlights("Straße", "ss")).toEqual([{ start: 4, end: 5 }]); + expect(findSearchHighlights("Cafe\u0301", "cafe")).toEqual([{ start: 0, end: 5 }]); + expect(findSearchHighlights("banana", "ana nan")).toEqual([{ start: 1, end: 6 }]); + expect(findSearchHighlights("Alpha/Béta", "beta alpha")).toEqual([ + { start: 0, end: 5 }, + { start: 6, end: 10 }, + ]); + expect(findSearchHighlights("𝒜lpha", "alpha")).toEqual([{ start: 0, end: 6 }]); + expect(findSearchHighlights("anything", "\u0301")).toEqual([]); + }); + + it("deduplicates terms and ranks matched-term count before history usage", () => { + expect(getSearchRelevance("alpha/beta", searchTerms("ALPHA alpha beta"), "beta")).toEqual({ matchedTermCount: 2, matchRank: 0 }); + const rows: SearchResult[] = [ + { + kind: "result", + id: "history", + value: "history", + icon: "recent-directory", + primaryText: "alpha", + matchedTermCount: 1, + matchRank: 0, + usageScore: 1000, + }, + { kind: "result", id: "index", value: "index", icon: "directory", primaryText: "alpha/beta", matchedTermCount: 2, matchRank: 3 }, + ]; + expect(rankHistorySearchResults(rows, "alpha beta").map((row) => row.value)).toEqual(["index", "history"]); + }); + + it("rejects invalid characters and excess input without silently dropping terms", () => { + expect(() => searchTerms("x".repeat(MAX_SEARCH_QUERY_BYTES + 1))).toThrow("UTF-8 bytes"); + expect(() => searchTerms(Array.from({ length: MAX_SEARCH_TERMS + 1 }, (_, index) => `term${index}`).join(" "))).toThrow( + "distinct terms" + ); + expect(() => searchTerms("\ud800")).toThrow("unsupported character"); + }); +}); diff --git a/frontend/src/components/FileBrowser/search/__tests__/useDirectorySearchProvider.test.ts b/frontend/src/components/FileBrowser/search/__tests__/useDirectorySearchProvider.test.ts index 26518d1b5..7fc0950c4 100644 --- a/frontend/src/components/FileBrowser/search/__tests__/useDirectorySearchProvider.test.ts +++ b/frontend/src/components/FileBrowser/search/__tests__/useDirectorySearchProvider.test.ts @@ -1,9 +1,13 @@ import { act, renderHook } from "@testing-library/react"; -import { createElement, type ReactNode } from "react"; +import { createElement, type ReactNode, useEffect } from "react"; import { describe, expect, it, vi } from "vitest"; import "../../../../i18n"; import api from "../../../../services/api"; +import { createBrowserContentServices } from "../../../../services/browserContentServices"; +import { type BrowserHistoryService, browserHistoryService } from "../../../../services/browserHistoryService"; +import { CompanionSession } from "../../../../services/companionSession"; import { SambeeThemeProvider } from "../../../../theme/ThemeContext"; +import { SEARCH_CONTRACT_VERSION } from "../recentHistory"; import { getDirectoryResultPresentation, useDirectorySearchProvider } from "../useDirectorySearchProvider"; vi.mock("../../../../services/api"); @@ -16,6 +20,342 @@ function wrapper({ children }: { children: ReactNode }) { } describe("getDirectoryResultPresentation", () => { + it.each(["connection-1", "local-C"])("fences old generation responses and retry errors for %s", async (connectionId) => { + type Response = Awaited>; + const services = createBrowserContentServices([], new CompanionSession()); + const healthy: Response = { + results: ["Current Reports"], + total_matches: 1, + directory_count: 1, + cache_state: "ready", + monitoring_state: "healthy", + search_contract_version: SEARCH_CONTRACT_VERSION, + }; + let finishOld!: (response: Response) => void; + let failOld!: (error: unknown) => void; + let finishActivation!: (response: Response) => void; + vi.mocked(api.searchRecentDirectories).mockResolvedValue({ result_limit: 10, results: [] }); + vi.mocked(api.searchDirectories) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishActivation = resolve; + }) + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishOld = resolve; + }) + ) + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failOld = reject; + }) + ) + .mockResolvedValue(healthy); + const { result, rerender, unmount } = renderHook( + ({ revision }) => useDirectorySearchProvider(connectionId, vi.fn(), { history: services.history, sourceRevision: revision }), + { wrapper, initialProps: { revision: services.getSnapshot().capabilityRevision } } + ); + const staleUpdate = vi.fn(); + let oldSearch!: ReturnType; + let oldFailure!: ReturnType; + await act(async () => { + result.current.onActivate?.(); + oldSearch = result.current.fetchResults("reports", new AbortController().signal, staleUpdate); + oldFailure = result.current.fetchResults("reports", new AbortController().signal, staleUpdate); + await Promise.resolve(); + }); + staleUpdate.mockClear(); + const oldKey = result.current.sourceKey; + services.updateCompanionSnapshot({ ...services.getSnapshot().companion, revision: 1 }); + rerender({ revision: services.getSnapshot().capabilityRevision }); + expect(result.current.sourceKey).not.toBe(oldKey); + await act(async () => { + expect(await result.current.fetchResults("reports", new AbortController().signal)).toEqual([ + expect.objectContaining({ value: "directory:Current Reports" }), + ]); + }); + await act(async () => { + const stale: Response = { ...healthy, results: ["Stale Reports"], cache_state: "building", retry_after_ms: 60_000 }; + finishActivation(stale); + finishOld(stale); + failOld({ response: { status: 501, data: { detail: { recoverable: false } } } }); + expect(await oldSearch).toEqual([]); + expect(await oldFailure).toEqual([]); + }); + expect(staleUpdate).not.toHaveBeenCalled(); + expect(result.current.getStatusInfo()).toBeNull(); + expect(result.current.getRefreshIntervalMs?.()).toBeNull(); + vi.mocked(api.searchDirectories).mockResolvedValue({ ...healthy, monitoring_state: "recovering", retry_after_ms: 2000 }); + await act(async () => { + await result.current.fetchResults("reports", new AbortController().signal); + }); + expect(result.current.getRefreshIntervalMs?.()).toBe(2000); + unmount(); + services.dispose(); + }); + + it("publishes new rows without recommitting unchanged status and still publishes changed coverage", async () => { + vi.mocked(api.searchRecentDirectories).mockResolvedValue({ result_limit: 10, results: [] }); + const response: Awaited> = { + results: ["Reports"], + total_matches: 1, + directory_count: 100_000, + cache_state: "ready", + coverage: "complete", + coverage_issue_count: 0, + last_verified_at_ms: 1, + search_contract_version: SEARCH_CONTRACT_VERSION, + }; + vi.mocked(api.searchDirectories).mockResolvedValue(response); + let commits = 0; + const onNavigate = vi.fn(); + const { result } = renderHook( + () => { + const provider = useDirectorySearchProvider("connection-1", onNavigate); + useEffect(() => { + commits += 1; + }); + return provider; + }, + { wrapper } + ); + await act(async () => { + await result.current.fetchResults("reports", new AbortController().signal); + }); + const settledCommits = commits; + vi.mocked(api.searchDirectories).mockResolvedValue({ ...response, results: ["Quarterly Reports"] }); + let rows: Awaited> = []; + await act(async () => { + rows = await result.current.fetchResults("quarterly", new AbortController().signal); + }); + expect(rows.some((row) => row.kind === "result" && row.value === "directory:Quarterly Reports")).toBe(true); + expect(commits).toBe(settledCommits); + const statusMessage = "Directory indexing is paused. Increase its disk budget and retry."; + vi.mocked(api.searchDirectories).mockResolvedValue({ + ...response, + coverage: "partial", + coverage_issue_count: 1, + last_verified_at_ms: 2, + status_message: statusMessage, + }); + await act(async () => { + await result.current.fetchResults("reports", new AbortController().signal); + }); + expect(commits).toBeGreaterThan(settledCommits); + expect(result.current.getStatusInfo?.()).toEqual({ label: statusMessage, showSpinner: false }); + }); + + it.each([ + { cache_state: "building", search_contract_version: SEARCH_CONTRACT_VERSION, expected: 500 }, + { cache_state: "updating", search_contract_version: SEARCH_CONTRACT_VERSION, expected: 500 }, + { cache_state: "ready", search_contract_version: SEARCH_CONTRACT_VERSION, expected: null }, + { cache_state: "empty", search_contract_version: SEARCH_CONTRACT_VERSION, expected: 5000 }, + { + cache_state: "ready", + coverage: "partial", + status_message: "Directory indexing is paused. Increase its disk budget and retry.", + search_contract_version: SEARCH_CONTRACT_VERSION, + expected: 5000, + }, + { cache_state: "building", search_contract_version: undefined, expected: null }, + { cache_state: "building", search_contract_version: null, expected: null }, + { cache_state: "building", search_contract_version: SEARCH_CONTRACT_VERSION + 1, expected: null }, + ] as const)("gates quiet refresh for $cache_state contract $search_contract_version", async ({ expected, ...status }) => { + vi.mocked(api.searchRecentDirectories).mockResolvedValue({ result_limit: 10, results: [] }); + vi.mocked(api.searchDirectories).mockResolvedValue({ results: [], total_matches: 0, directory_count: 0, ...status }); + const { result } = renderHook(() => useDirectorySearchProvider("connection-1", vi.fn()), { wrapper }); + await act(async () => { + await result.current.fetchResults("reports", new AbortController().signal); + }); + expect(result.current.getRefreshIntervalMs?.()).toBe(expected); + }); + + it.each(["building", "updating"] as const)("keeps status refresh when a %s query becomes too short", async (cacheState) => { + vi.mocked(api.searchRecentDirectories).mockResolvedValue({ result_limit: 10, results: [] }); + vi.mocked(api.searchDirectories).mockClear(); + vi.mocked(api.searchDirectories).mockResolvedValue({ + results: [], + total_matches: 0, + directory_count: 0, + cache_state: cacheState, + search_contract_version: SEARCH_CONTRACT_VERSION, + }); + const { result } = renderHook(() => useDirectorySearchProvider("connection-1", vi.fn()), { wrapper }); + await act(async () => { + await result.current.fetchResults("reports", new AbortController().signal); + }); + expect(result.current.getRefreshIntervalMs?.()).toBe(500); + + await act(async () => { + await result.current.fetchResults("r", new AbortController().signal); + }); + expect(api.searchDirectories).toHaveBeenCalledTimes(2); + expect(api.searchDirectories).toHaveBeenLastCalledWith("connection-1", "", expect.anything()); + expect(result.current.getRefreshIntervalMs?.()).toBe(500); + + await act(async () => { + await result.current.fetchResults("reports", new AbortController().signal); + }); + expect(api.searchDirectories).toHaveBeenCalledTimes(3); + expect(result.current.getRefreshIntervalMs?.()).toBe(500); + }); + + it.each(["connection-1", "local-C"])("keeps one full-query request and source-specific legacy status for %s", async (connectionId) => { + vi.mocked(api.searchRecentDirectories).mockResolvedValue({ result_limit: 10, results: [] }); + const response = { + results: ["alpha/beta"], + total_matches: 100_001, + directory_count: 100_001, + cache_state: "ready" as const, + }; + vi.mocked(api.searchDirectories).mockResolvedValue(response); + vi.mocked(api.searchDirectories).mockClear(); + const { result } = renderHook(() => useDirectorySearchProvider(connectionId, vi.fn()), { wrapper }); + let rows: Awaited> = []; + await act(async () => { + rows = await result.current.fetchResults("alpha beta", new AbortController().signal); + }); + expect(api.searchDirectories).toHaveBeenCalledTimes(1); + expect(api.searchDirectories).toHaveBeenCalledWith(connectionId, "alpha beta", expect.objectContaining({ signal: expect.anything() })); + expect(rows.some((row) => row.kind === "result" && row.value === "directory:alpha/beta")).toBe(true); + expect(result.current.getStatusInfo?.()).toEqual({ + label: + "Multi-term directory search is not confirmed for this source. Search one directory term or check backend and companion support.", + showSpinner: false, + }); + vi.mocked(api.searchDirectories).mockResolvedValue({ ...response, search_contract_version: SEARCH_CONTRACT_VERSION }); + await act(async () => { + await result.current.fetchResults("alpha beta", new AbortController().signal); + }); + expect(api.searchDirectories).toHaveBeenCalledTimes(2); + expect(result.current.getStatusInfo?.()).toBeNull(); + }); + + it("reuses same-query history, retains bounded recovery, and reloads invalidated history", async () => { + vi.mocked(api.searchRecentDirectories).mockResolvedValue({ result_limit: 10, results: [] }); + vi.mocked(api.searchDirectories) + .mockResolvedValueOnce({ + results: [], + total_matches: 0, + directory_count: 0, + cache_state: "building", + search_contract_version: SEARCH_CONTRACT_VERSION, + }) + .mockRejectedValueOnce(new Error("Index unavailable")) + .mockResolvedValue({ + results: [], + total_matches: 0, + directory_count: 0, + cache_state: "ready", + search_contract_version: SEARCH_CONTRACT_VERSION, + }); + const { result } = renderHook(() => useDirectorySearchProvider("connection-1", vi.fn()), { wrapper }); + await act(async () => { + await result.current.fetchResults("reports", new AbortController().signal); + }); + expect(result.current.getRefreshIntervalMs?.()).toBe(500); + await act(async () => { + await result.current.fetchResults("reports", new AbortController().signal); + }); + expect(result.current.getRefreshIntervalMs?.()).toBe(5000); + expect(api.searchRecentDirectories).toHaveBeenCalledTimes(1); + result.current.invalidateResults?.(); + await act(async () => { + await result.current.fetchResults("reports", new AbortController().signal); + }); + expect(api.searchRecentDirectories).toHaveBeenCalledTimes(2); + await act(async () => { + await result.current.fetchResults("other", new AbortController().signal); + }); + expect(api.searchRecentDirectories).toHaveBeenCalledTimes(3); + expect(api.searchDirectories).toHaveBeenCalledTimes(4); + }); + + it("keeps the history representation with the best valid duplicate relevance", async () => { + vi.mocked(api.searchRecentDirectories).mockResolvedValue({ + result_limit: 10, + results: [ + { + id: "recent-reports", + connection_id: "connection-1", + path: "Documents/Reports", + last_visited_at: "2026-01-01T00:00:00Z", + matched_term_count: 99, + match_rank: -1, + }, + ], + }); + vi.mocked(api.searchDirectories).mockResolvedValue({ + results: ["Documents/Reports"], + total_matches: 1, + cache_state: "ready", + directory_count: 1, + matched_term_counts: { "Documents/Reports": 2 }, + match_ranks: { "Documents/Reports": 0 }, + }); + const { result } = renderHook(() => useDirectorySearchProvider("connection-1", vi.fn()), { wrapper }); + let rows: Awaited> = []; + await act(async () => { + rows = await result.current.fetchResults("doc report", new AbortController().signal); + }); + expect(rows).toEqual([ + expect.objectContaining({ + value: "recent-directory:recent-reports", + icon: "recent-directory", + matchedTermCount: 2, + matchRank: 0, + }), + ]); + }); + + it("retains results and shows an incomplete source message even when cache state is ready", async () => { + vi.mocked(api.searchRecentDirectories).mockResolvedValue({ result_limit: 10, results: [] }); + vi.mocked(api.searchDirectories).mockResolvedValue({ + results: ["Reports"], + total_matches: 1, + cache_state: "ready", + directory_count: 1, + coverage: "partial", + coverage_issue_count: 1, + status_message: "Directory indexing is paused. Increase its disk budget and retry.", + }); + const { result } = renderHook(() => useDirectorySearchProvider("connection-1", vi.fn()), { wrapper }); + let rows: Awaited> = []; + await act(async () => { + rows = await result.current.fetchResults("Reports", new AbortController().signal); + }); + expect(rows.some((row) => row.kind === "result" && row.value === "directory:Reports")).toBe(true); + expect(result.current.getStatusInfo?.()).toEqual({ + label: "Directory indexing is paused. Increase its disk budget and retry.", + showSpinner: false, + }); + }); + + it("does not let a late warm-up overwrite a newer query status", async () => { + vi.mocked(api.searchRecentDirectories).mockResolvedValue({ result_limit: 10, results: [] }); + let finishWarmup!: (response: Awaited>) => void; + vi.mocked(api.searchDirectories).mockImplementation((_connection, query) => + query === "" + ? new Promise((resolve) => { + finishWarmup = resolve; + }) + : Promise.resolve({ results: ["Reports"], total_matches: 1, cache_state: "ready", directory_count: 1, coverage: "complete" }) + ); + const { result } = renderHook(() => useDirectorySearchProvider("connection-1", vi.fn()), { wrapper }); + act(() => result.current.onActivate?.()); + await act(async () => { + await result.current.fetchResults("Reports", new AbortController().signal); + }); + await act(async () => { + finishWarmup({ results: [], total_matches: 0, cache_state: "building", directory_count: 0, coverage: "partial" }); + }); + expect(result.current.getStatusInfo?.()).toBeNull(); + }); + it("returns proportional-renderer data with a highlighted smart path", () => { expect(getDirectoryResultPresentation("Documents/Quarterly Reports/2026", "Reports")).toEqual({ primaryText: "…/Quarterly Reports/2026", @@ -76,14 +416,18 @@ describe("getDirectoryResultPresentation", () => { { wrapper } ); - const emptyResults = await result.current.fetchResults("", new AbortController().signal); - expect(api.searchDirectories).not.toHaveBeenCalled(); + let emptyResults: Awaited> = []; + await act(async () => { + emptyResults = await result.current.fetchResults("", new AbortController().signal); + }); + expect(api.searchDirectories).toHaveBeenCalledWith("connection-1", "", expect.anything()); expect(emptyResults.map((entry) => ({ kind: entry.kind, value: entry.value }))).toEqual([ { kind: "group-header", value: "" }, { kind: "result", value: "recent-directory:recent-reports" }, { kind: "result", value: "recent-directory:recent-other-connection" }, ]); expect(emptyResults[1]).toMatchObject({ + icon: "recent-directory", primaryText: "…/Reports", secondaryText: "Documents server:/Documents/", }); @@ -98,6 +442,9 @@ describe("getDirectoryResultPresentation", () => { { kind: "result", value: "recent-directory:recent-other-connection" }, { kind: "result", value: "directory:Documents/Reference" }, ]); + expect(results[0]).toMatchObject({ icon: "recent-directory" }); + expect(results[1]).toMatchObject({ icon: "recent-directory" }); + expect(results[2]).toMatchObject({ icon: "directory" }); result.current.onSelect("recent-directory:recent-other-connection"); expect(onNavigateDirectory).toHaveBeenCalledWith("connection-2", "Projects/2026", "recent-other-connection"); @@ -141,15 +488,17 @@ describe("getDirectoryResultPresentation", () => { expect(results[0]).toMatchObject({ matchRank: 0 }); }); - it("queries recent directories but skips cache search for a one-character query", async () => { + it("queries recent directories and index status for a one-character query", async () => { vi.mocked(api.searchDirectories).mockClear(); vi.mocked(api.searchRecentDirectories).mockResolvedValue({ result_limit: 10, results: [] }); const { result } = renderHook(() => useDirectorySearchProvider("connection-1", vi.fn()), { wrapper }); - await result.current.fetchResults("r", new AbortController().signal); + await act(async () => { + await result.current.fetchResults("r", new AbortController().signal); + }); expect(api.searchRecentDirectories).toHaveBeenCalledWith("r", 50, expect.any(AbortSignal)); - expect(api.searchDirectories).not.toHaveBeenCalled(); + expect(api.searchDirectories).toHaveBeenCalledWith("connection-1", "", expect.anything()); }); it("returns no results for an aborted directory request", async () => { @@ -167,10 +516,331 @@ describe("getDirectoryResultPresentation", () => { await expect(result.current.fetchResults("re", controller.signal)).resolves.toEqual([]); }); - it("propagates a recent-directory search failure to the shared Quick Bar error UI", async () => { + it("shows complete source failure and keeps bounded recovery active", async () => { vi.mocked(api.searchRecentDirectories).mockRejectedValue(new Error("Network error")); + vi.mocked(api.searchDirectories).mockRejectedValue(new Error("Index error")); + const { result } = renderHook(() => useDirectorySearchProvider("connection-1", vi.fn()), { wrapper }); + + await act(async () => { + await expect(result.current.fetchResults("reports", new AbortController().signal)).resolves.toEqual([]); + }); + expect(result.current.getStatusInfo()).toMatchObject({ showSpinner: false }); + expect(result.current.getRefreshIntervalMs?.()).toBe(5000); + }); + + it.each(["connection-1", "local-C"])("retains results and stops terminal retries for %s", async (connectionId) => { + vi.mocked(api.searchRecentDirectories).mockResolvedValue({ result_limit: 10, results: [] }); + vi.mocked(api.searchDirectories) + .mockResolvedValueOnce({ + results: ["Reports"], + total_matches: 1, + directory_count: 1, + cache_state: "ready", + search_contract_version: SEARCH_CONTRACT_VERSION, + }) + .mockRejectedValue({ response: { status: 503, data: { detail: { recoverable: true, retry_after_ms: 900_000 } } } }); + const { result } = renderHook(() => useDirectorySearchProvider(connectionId, vi.fn()), { wrapper }); + await act(async () => { + await result.current.fetchResults("reports", new AbortController().signal); + }); + await act(async () => { + const rows = await result.current.fetchResults("reports", new AbortController().signal); + expect(rows).toEqual([expect.objectContaining({ value: "directory:Reports" })]); + }); + expect(result.current.getRefreshIntervalMs?.()).toBe(60_000); + vi.mocked(api.searchDirectories).mockRejectedValue({ response: { status: 501, data: { detail: { recoverable: false } } } }); + await act(async () => { + await result.current.fetchResults("reports", new AbortController().signal); + }); + expect(result.current.getRefreshIntervalMs?.()).toBeNull(); + }); + + it.each(["connection-1", "local-C"])( + "keeps terminal index failures visible across query and history changes for %s", + async (connectionId) => { + vi.mocked(api.searchRecentDirectories).mockResolvedValue({ result_limit: 10, results: [] }); + vi.mocked(api.searchDirectories).mockRejectedValue({ response: { status: 400 } }); + const { result } = renderHook(() => useDirectorySearchProvider(connectionId, vi.fn()), { wrapper }); + await act(async () => { + await result.current.fetchResults("reports", new AbortController().signal); + }); + const failure = result.current.getStatusInfo(); + expect(failure?.label).toContain("Directory search is unavailable"); + expect(result.current.getRefreshIntervalMs?.()).toBeNull(); + const requests = vi.mocked(api.searchDirectories).mock.calls.length; + await act(async () => { + await result.current.fetchResults("archive", new AbortController().signal); + }); + expect(result.current.getStatusInfo()).toEqual(failure); + vi.mocked(api.searchRecentDirectories).mockRejectedValueOnce(new Error("History unavailable")); + await act(async () => { + await result.current.fetchResults("other", new AbortController().signal); + }); + expect(result.current.getStatusInfo()).toEqual(failure); + expect(api.searchDirectories).toHaveBeenCalledTimes(requests); + vi.mocked(api.searchDirectories).mockResolvedValue({ + results: [], + total_matches: 0, + directory_count: 0, + cache_state: "ready", + search_contract_version: SEARCH_CONTRACT_VERSION, + }); + await act(async () => result.current.onActivate?.()); + expect(result.current.getStatusInfo()).toBeNull(); + expect(api.searchDirectories).toHaveBeenCalledTimes(requests + 1); + } + ); + + it.each(["connection-1", "local-C"])("retries temporary query saturation for %s", async (connectionId) => { + vi.mocked(api.searchRecentDirectories).mockResolvedValue({ result_limit: 10, results: [] }); + vi.mocked(api.searchDirectories) + .mockRejectedValueOnce({ response: { status: 429 } }) + .mockResolvedValue({ + results: [], + total_matches: 0, + directory_count: 0, + cache_state: "ready", + search_contract_version: SEARCH_CONTRACT_VERSION, + }); + const { result } = renderHook(() => useDirectorySearchProvider(connectionId, vi.fn()), { wrapper }); + await act(async () => { + await result.current.fetchResults("reports", new AbortController().signal); + }); + expect(result.current.getRefreshIntervalMs?.()).toBe(5000); + expect(result.current.getStatusInfo()?.label).toContain("Directory search is unavailable"); + await act(async () => { + await result.current.fetchResults("reports", new AbortController().signal); + }); + expect(result.current.getStatusInfo()).toBeNull(); + expect(result.current.getRefreshIntervalMs?.()).toBeNull(); + }); + + it("shows activation failure and stops recovery on deactivation", async () => { + vi.mocked(api.searchDirectories).mockRejectedValue(new Error("unavailable")); + const { result } = renderHook(() => useDirectorySearchProvider("connection-1", vi.fn()), { wrapper }); + await act(async () => { + result.current.onActivate?.(); + }); + expect(result.current.getStatusInfo()).toMatchObject({ showSpinner: false }); + expect(result.current.getRefreshIntervalMs?.()).toBe(5000); + act(() => result.current.onDeactivate?.()); + expect(result.current.getRefreshIntervalMs?.()).toBeNull(); + }); + + it.each(["connection-1", "local-C"])("recovers from paused activation and request failure for %s", async (connectionId) => { + const healthy = { + results: ["Reports"], + total_matches: 1, + directory_count: 1, + cache_state: "ready" as const, + monitoring_state: "healthy" as const, + search_contract_version: SEARCH_CONTRACT_VERSION, + }; + vi.mocked(api.searchRecentDirectories).mockResolvedValue({ + result_limit: 10, + results: [{ id: "recent", connection_id: connectionId, path: "Other Reports", last_visited_at: "2026-01-01T00:00:00Z" }], + }); + vi.mocked(api.searchDirectories) + .mockResolvedValueOnce({ ...healthy, results: [], cache_state: "building", monitoring_state: "unavailable", retry_after_ms: 30_000 }) + .mockResolvedValueOnce({ ...healthy, monitoring_state: "recovering" }) + .mockRejectedValueOnce({ response: { status: 503, data: { detail: { recoverable: true, retry_after_ms: 2000 } } } }) + .mockResolvedValue(healthy); + const { result } = renderHook(() => useDirectorySearchProvider(connectionId, vi.fn()), { wrapper }); + await act(async () => result.current.onActivate?.()); + expect(api.searchDirectories).toHaveBeenLastCalledWith(connectionId, "", expect.anything()); + expect(result.current.getStatusInfo()).toMatchObject({ showSpinner: false }); + expect(result.current.getRefreshIntervalMs?.()).toBe(30_000); + await act(async () => { + await result.current.fetchResults("reports", new AbortController().signal); + }); + expect(result.current.getRefreshIntervalMs?.()).toBe(5000); + await act(async () => { + const retained = await result.current.fetchResults("reports", new AbortController().signal); + expect(retained).toEqual( + expect.arrayContaining([ + expect.objectContaining({ value: "directory:Reports" }), + expect.objectContaining({ value: "recent-directory:recent" }), + ]) + ); + }); + expect(result.current.getStatusInfo()).toMatchObject({ showSpinner: false }); + expect(result.current.getRefreshIntervalMs?.()).toBe(2000); + await act(async () => { + await result.current.fetchResults("reports", new AbortController().signal); + }); + expect(result.current.getStatusInfo()).toBeNull(); + expect(result.current.getRefreshIntervalMs?.()).toBeNull(); + expect(api.searchRecentDirectories).toHaveBeenCalledTimes(1); + expect(api.searchDirectories).toHaveBeenCalledTimes(4); + act(() => result.current.onDeactivate?.()); + expect(result.current.getRefreshIntervalMs?.()).toBeNull(); + }); + + it("rejects a response that arrives after deactivation", async () => { + type Response = Awaited>; + let finish: ((response: Response) => void) | undefined; + vi.mocked(api.searchRecentDirectories).mockResolvedValue({ result_limit: 10, results: [] }); + vi.mocked(api.searchDirectories).mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }) + ); const { result } = renderHook(() => useDirectorySearchProvider("connection-1", vi.fn()), { wrapper }); + let pending: ReturnType | undefined; + const onUpdate = vi.fn(); + await act(async () => { + pending = result.current.fetchResults("reports", new AbortController().signal, onUpdate); + await Promise.resolve(); + }); + onUpdate.mockClear(); + act(() => result.current.onDeactivate?.()); + await act(async () => { + finish?.({ + results: ["Stale Reports"], + total_matches: 1, + directory_count: 1, + cache_state: "building", + search_contract_version: SEARCH_CONTRACT_VERSION, + }); + expect(await pending).toEqual([]); + }); + expect(onUpdate).not.toHaveBeenCalled(); + expect(result.current.getRefreshIntervalMs?.()).toBeNull(); + }); - await expect(result.current.fetchResults("reports", new AbortController().signal)).rejects.toThrow("Network error"); + it.each(["connection-1", "local-C"])("rejects old-client results after same-source replacement for %s", async (connectionId) => { + type Response = Awaited>; + let finishOld: ((response: Response) => void) | undefined; + const oldHistory: BrowserHistoryService = { + ...browserHistoryService, + searchRecentDirectories: vi.fn().mockResolvedValue({ result_limit: 10, results: [] }), + searchDirectories: vi.fn( + () => + new Promise((resolve) => { + finishOld = resolve; + }) + ), + }; + const newHistory: BrowserHistoryService = { + ...oldHistory, + searchDirectories: vi.fn().mockResolvedValue({ + results: ["Current Reports"], + total_matches: 1, + directory_count: 1, + cache_state: "ready", + monitoring_state: "healthy", + search_contract_version: SEARCH_CONTRACT_VERSION, + }), + }; + const { result, rerender } = renderHook(({ history }) => useDirectorySearchProvider(connectionId, vi.fn(), { history }), { + wrapper, + initialProps: { history: oldHistory }, + }); + const staleUpdate = vi.fn(); + let pending: ReturnType | undefined; + await act(async () => { + pending = result.current.fetchResults("reports", new AbortController().signal, staleUpdate); + await Promise.resolve(); + }); + staleUpdate.mockClear(); + const oldSourceKey = result.current.sourceKey; + rerender({ history: newHistory }); + expect(result.current.sourceKey).not.toBe(oldSourceKey); + await act(async () => { + const current = await result.current.fetchResults("reports", new AbortController().signal); + expect(current).toEqual([expect.objectContaining({ value: "directory:Current Reports" })]); + }); + await act(async () => { + finishOld?.({ + results: ["Stale Reports"], + total_matches: 1, + directory_count: 1, + cache_state: "building", + search_contract_version: SEARCH_CONTRACT_VERSION, + }); + expect(await pending).toEqual([]); + }); + expect(staleUpdate).not.toHaveBeenCalled(); + expect(result.current.getRefreshIntervalMs?.()).toBeNull(); + expect(result.current.getStatusInfo()).toBeNull(); + }); + + it("publishes selectable history before a deferred index response", async () => { + const onNavigateDirectory = vi.fn(); + const onUpdate = vi.fn(); + let finishIndex: ((response: Awaited>) => void) | undefined; + vi.mocked(api.searchRecentDirectories).mockResolvedValue({ + result_limit: 10, + results: [{ id: "recent", connection_id: "connection-1", path: "Reports", last_visited_at: "2026-01-01T00:00:00Z" }], + }); + vi.mocked(api.searchDirectories).mockImplementation( + () => + new Promise((resolve) => { + finishIndex = resolve; + }) + ); + const { result } = renderHook(() => useDirectorySearchProvider("connection-1", vi.fn(), { onNavigateDirectory }), { wrapper }); + let request: ReturnType | undefined; + await act(async () => { + request = result.current.fetchResults("reports", new AbortController().signal, onUpdate); + await Promise.resolve(); + }); + expect(onUpdate).toHaveBeenCalledWith([expect.objectContaining({ value: "recent-directory:recent", icon: "recent-directory" })]); + result.current.onSelect("recent-directory:recent"); + expect(onNavigateDirectory).toHaveBeenCalledWith("connection-1", "Reports", "recent"); + await act(async () => { + finishIndex?.({ results: ["Reports", "Other Reports"], cache_state: "ready", total_matches: 2, directory_count: 100_001 }); + await request; + }); + expect(onUpdate.mock.lastCall?.[0]).toHaveLength(2); + expect(onUpdate.mock.lastCall?.[0][0]).toMatchObject({ icon: "recent-directory" }); + }); + + it("keeps history when index search fails and stops the status spinner", async () => { + vi.mocked(api.searchRecentDirectories).mockResolvedValue({ + result_limit: 10, + results: [{ id: "recent", connection_id: "connection-1", path: "Reports", last_visited_at: "2026-01-01T00:00:00Z" }], + }); + vi.mocked(api.searchDirectories).mockRejectedValue(new Error("Index unavailable")); + const { result } = renderHook(() => useDirectorySearchProvider("connection-1", vi.fn()), { wrapper }); + await act(async () => { + const rows = await result.current.fetchResults("reports", new AbortController().signal); + expect(rows).toEqual([expect.objectContaining({ value: "recent-directory:recent" })]); + }); + expect(result.current.getStatusInfo()).toMatchObject({ showSpinner: false }); + expect(result.current.getStatusInfo()?.label).toContain("Directory search is unavailable"); + }); + + it("keeps index results when history search fails", async () => { + vi.mocked(api.searchRecentDirectories).mockRejectedValue(new Error("History unavailable")); + vi.mocked(api.searchDirectories).mockResolvedValue({ + results: ["Reports"], + cache_state: "ready", + total_matches: 1, + directory_count: 1, + }); + const { result } = renderHook(() => useDirectorySearchProvider("connection-1", vi.fn()), { wrapper }); + await act(async () => { + const rows = await result.current.fetchResults("reports", new AbortController().signal); + expect(rows).toEqual([expect.objectContaining({ value: "directory:Reports" })]); + }); + expect(result.current.getStatusInfo()?.label).toContain("Recent directories are unavailable"); + }); + + it("aborts activation on deactivation and unmount", async () => { + let signal: AbortSignal | undefined; + vi.mocked(api.searchDirectories).mockImplementation((_connection, _query, options) => { + signal = options?.signal; + return new Promise(() => undefined); + }); + const { result, unmount } = renderHook(() => useDirectorySearchProvider("connection-1", vi.fn()), { wrapper }); + result.current.onActivate?.(); + expect(signal?.aborted).toBe(false); + result.current.onDeactivate?.(); + expect(signal?.aborted).toBe(true); + result.current.onActivate?.(); + unmount(); + expect(signal?.aborted).toBe(true); }); }); diff --git a/frontend/src/components/FileBrowser/search/__tests__/useFileSearchProvider.test.tsx b/frontend/src/components/FileBrowser/search/__tests__/useFileSearchProvider.test.tsx index 5bf5ddab8..78a0eeeae 100644 --- a/frontend/src/components/FileBrowser/search/__tests__/useFileSearchProvider.test.tsx +++ b/frontend/src/components/FileBrowser/search/__tests__/useFileSearchProvider.test.tsx @@ -1,4 +1,4 @@ -import { render, renderHook, screen } from "@testing-library/react"; +import { act, render, renderHook, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import "../../../../i18n"; import api from "../../../../services/api"; @@ -20,6 +20,122 @@ function wrapper({ children }: { children: React.ReactNode }) { } describe("useFileSearchProvider", () => { + it("publishes selectable loaded files before history and keeps them if history fails", async () => { + let rejectHistory!: (error: Error) => void; + vi.mocked(api.searchRecentFiles).mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectHistory = reject; + }) + ); + const onOpenCurrentFile = vi.fn(); + const { result } = renderHook( + () => + useFileSearchProvider({ + connectionId: "connection-1", + currentPath: "Reports", + files, + connectionName: "Reports server", + resultLimit: 10, + getConnectionName: () => "Reports server", + onOpenCurrentFile, + onOpenRecentFile: vi.fn(), + }), + { wrapper } + ); + const onUpdate = vi.fn(); + let pending!: ReturnType; + act(() => { + pending = result.current.fetchResults("report", new AbortController().signal, onUpdate); + }); + expect(onUpdate).toHaveBeenCalledWith(expect.arrayContaining([expect.objectContaining({ value: "current:report.txt" })])); + result.current.onSelect("current:report.txt"); + expect(onOpenCurrentFile).toHaveBeenCalledWith(expect.objectContaining({ name: "report.txt" }), "associated-viewer"); + await act(async () => { + rejectHistory(new Error("offline")); + await pending; + }); + expect(await pending).toEqual(onUpdate.mock.calls[0]?.[0]); + expect(result.current.getStatusInfo()).toEqual({ + label: "Recent files are unavailable. Check the backend connection and retry.", + showSpinner: false, + }); + }); + + it("does not let an older history completion replace a newer selection map", async () => { + let finishOld!: (response: Awaited>) => void; + vi.mocked(api.searchRecentFiles).mockImplementationOnce( + () => + new Promise((resolve) => { + finishOld = resolve; + }) + ); + vi.mocked(api.searchRecentFiles).mockResolvedValueOnce({ + results: [ + { + id: "new", + connection_id: "connection-2", + path: "summary-new.txt", + file_name: "summary-new.txt", + last_opened_at: "2026-01-01T00:00:00Z", + }, + ], + }); + const onOpenRecentFile = vi.fn(); + const { result } = renderHook( + () => + useFileSearchProvider({ + connectionId: "connection-1", + currentPath: "Reports", + files, + connectionName: "Reports server", + resultLimit: 10, + getConnectionName: () => "Reports server", + onOpenCurrentFile: vi.fn(), + onOpenRecentFile, + }), + { wrapper } + ); + let oldRequest!: ReturnType; + act(() => { + oldRequest = result.current.fetchResults("report", new AbortController().signal); + }); + await act(async () => { + await result.current.fetchResults("summary", new AbortController().signal); + }); + await act(async () => { + finishOld({ results: [] }); + await oldRequest; + }); + expect(await oldRequest).toEqual([]); + result.current.onSelect("recent:new"); + expect(onOpenRecentFile).toHaveBeenCalledWith(expect.objectContaining({ id: "new" }), "associated-viewer"); + }); + + it("uses normalized Unicode code-point ties before limiting loaded files", async () => { + vi.mocked(api.searchRecentFiles).mockResolvedValue({ result_limit: 1, results: [] }); + const candidates = [ + { name: "report-\u{10000}.txt", type: FileType.FILE }, + { name: "report-\uE000.txt", type: FileType.FILE }, + ] as FileEntry[]; + const { result } = renderHook( + () => + useFileSearchProvider({ + connectionId: "connection-1", + currentPath: "Reports", + files: candidates, + connectionName: "Reports server", + resultLimit: 1, + getConnectionName: () => "Reports server", + onOpenCurrentFile: vi.fn(), + onOpenRecentFile: vi.fn(), + }), + { wrapper } + ); + const rows = await result.current.fetchResults("report", new AbortController().signal); + expect(rows.map((row) => row.value)).toEqual(["current:report-\uE000.txt"]); + }); + it("ranks exact matches across sources before frequency and preserves empty-query groups", () => { const results = [ { kind: "group-header" as const, id: "recent", value: "" as const, label: "Recent" }, diff --git a/frontend/src/components/FileBrowser/search/recentHistory.ts b/frontend/src/components/FileBrowser/search/recentHistory.ts index cf0e7735b..2da800285 100644 --- a/frontend/src/components/FileBrowser/search/recentHistory.ts +++ b/frontend/src/components/FileBrowser/search/recentHistory.ts @@ -1,28 +1,178 @@ -import type { SearchResult, SearchResultItem } from "./types"; +import unicode from "../../../../../shared/directory-index/unicode.json"; +import type { SearchResult, SearchResultItem, SearchTextHighlight } from "./types"; + +export const MAX_SEARCH_QUERY_BYTES = 4096; +export const MAX_SEARCH_TERMS = 32; +export const SEARCH_CONTRACT_VERSION = 1; +const NORMALIZATION_KEYS = new Map(unicode.normalization.map(([source, key]) => [source ?? "", key ?? ""])); +const QUERY_WHITESPACE = new Set(unicode.whitespace); + +function isWordCharacter(character: string): boolean { + const codepoint = character.codePointAt(0) ?? -1; + let low = 0; + let high = unicode.word_ranges.length - 1; + while (low <= high) { + const middle = Math.floor((low + high) / 2); + const range = unicode.word_ranges[middle]; + if (!range) return false; + const start = range[0] ?? -1; + const end = range[1] ?? -1; + if (codepoint < start) high = middle - 1; + else if (codepoint > end) low = middle + 1; + else return true; + } + return false; +} export function normalizeSearchText(value: string): string { - return value - .normalize("NFKD") - .replace(/[\u0300-\u036f]/g, "") - .toLocaleLowerCase(); + return Array.from(value, (character) => NORMALIZATION_KEYS.get(character) ?? character).join(""); } -export function historyMatchRank(name: string, query: string): number { - const normalizedName = normalizeSearchText(name); - const normalizedQuery = normalizeSearchText(query.trim()); - if (!normalizedQuery) return 3; - if (normalizedName === normalizedQuery) return 0; - if (normalizedName.startsWith(normalizedQuery)) return 1; - if (new RegExp(`(?:^|[^\\p{L}\\p{N}])${normalizedQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "u").test(normalizedName)) return 2; +export function searchTerms(query: string): string[] { + if (/[\uD800-\uDFFF]/u.test(query)) throw new Error("Search text contains an unsupported character. Remove that character and retry."); + if (new TextEncoder().encode(query).length > MAX_SEARCH_QUERY_BYTES) { + throw new Error(`Search text must use at most ${MAX_SEARCH_QUERY_BYTES} UTF-8 bytes. Shorten the search text.`); + } + const terms = new Set(); + let rawTerm = ""; + const addTerm = () => { + const term = normalizeSearchText(rawTerm); + if (term) terms.add(term); + rawTerm = ""; + }; + for (const character of query) { + if (QUERY_WHITESPACE.has(character)) addTerm(); + else rawTerm += character; + } + addTerm(); + if (terms.size > MAX_SEARCH_TERMS) + throw new Error(`Search text must contain at most ${MAX_SEARCH_TERMS} distinct terms. Remove some terms.`); + return [...terms].sort(compareText); +} + +function singleTermRank(name: string, term: string): number { + if (name === term) return 0; + if (name.startsWith(term)) return 1; + let offset = 0; + for (const character of name) { + offset += character.length; + if (!isWordCharacter(character) && name.startsWith(term, offset)) return 2; + } return 3; } +export function getSearchRelevance( + field: string, + terms: readonly string[], + basename = field +): { matchedTermCount: number; matchRank: number } { + const key = normalizeSearchText(field); + const basenameKey = normalizeSearchText(basename); + const matched = terms.filter((term) => key.includes(term)); + return { + matchedTermCount: matched.length, + matchRank: matched.length ? Math.min(...matched.map((term) => singleTermRank(basenameKey, term))) : 3, + }; +} + +export function bestSearchRelevance( + termCount: number, + ...candidates: Pick[] +): { matchedTermCount: number; matchRank: number } { + let best = { matchedTermCount: 0, matchRank: 3 }; + for (const candidate of candidates) { + const count = candidate.matchedTermCount; + const rank = candidate.matchRank; + if ( + count === undefined || + rank === undefined || + !Number.isSafeInteger(count) || + count < 0 || + count > termCount || + !Number.isInteger(rank) || + rank < 0 || + rank > 3 + ) + continue; + if (count > best.matchedTermCount || (count === best.matchedTermCount && rank < best.matchRank)) { + best = { matchedTermCount: count, matchRank: rank }; + } + } + return best; +} + +export function findSearchHighlights(text: string, query: string): SearchTextHighlight[] { + const terms = searchTerms(query); + let normalized = ""; + const offsets: SearchTextHighlight[] = []; + let originalOffset = 0; + for (const character of text) { + const key = normalizeSearchText(character); + const end = originalOffset + character.length; + if (!key && offsets.length) { + const previous = offsets[offsets.length - 1]; + if (previous) previous.end = end; + } + for (let index = 0; index < key.length; index += 1) offsets.push({ start: originalOffset, end }); + normalized += key; + originalOffset = end; + } + const ranges: SearchTextHighlight[] = []; + for (const term of terms) { + for (let start = normalized.indexOf(term); start >= 0; start = normalized.indexOf(term, start + 1)) { + const first = offsets[start]; + const last = offsets[start + term.length - 1]; + if (first && last) ranges.push({ start: first.start, end: last.end }); + } + } + ranges.sort((left, right) => left.start - right.start || left.end - right.end); + const merged: SearchTextHighlight[] = []; + for (const range of ranges) { + const previous = merged.at(-1); + if (previous && range.start <= previous.end) previous.end = Math.max(previous.end, range.end); + else merged.push({ ...range }); + } + return merged; +} + +export function findTextHighlight(text: string, query: string): SearchTextHighlight | SearchTextHighlight[] | undefined { + const ranges = findSearchHighlights(text, query); + return ranges.length === 1 ? ranges[0] : ranges.length ? ranges : undefined; +} + +export function historyMatchRank(name: string, query: string): number { + return getSearchRelevance(name, searchTerms(query)).matchRank; +} + /** Typed searches rank across sources; empty searches keep their history groups. */ export function rankHistorySearchResults(results: SearchResult[], query: string): SearchResult[] { - if (!query.trim()) return results; + if (!searchTerms(query).length) return results; return results .filter((result): result is SearchResultItem => result.kind === "result") - .sort((left, right) => (left.matchRank ?? 3) - (right.matchRank ?? 3) || (right.usageScore ?? 0) - (left.usageScore ?? 0)); + .sort( + (left, right) => + (right.matchedTermCount ?? 1) - (left.matchedTermCount ?? 1) || + (left.matchRank ?? 3) - (right.matchRank ?? 3) || + (right.usageScore ?? 0) - (left.usageScore ?? 0) || + (Date.parse(right.lastUsedAt ?? "") || 0) - (Date.parse(left.lastUsedAt ?? "") || 0) || + (left.depth ?? 0) - (right.depth ?? 0) || + Number(!left.icon.startsWith("recent-")) - Number(!right.icon.startsWith("recent-")) || + compareText(normalizeSearchText(left.sortPath ?? left.primaryText), normalizeSearchText(right.sortPath ?? right.primaryText)) || + compareText(left.sortPath ?? left.primaryText, right.sortPath ?? right.primaryText) || + compareText(left.value, right.value) + ); +} + +export function compareText(left: string, right: string): number { + const leftCharacters = left[Symbol.iterator](); + const rightCharacters = right[Symbol.iterator](); + while (true) { + const leftCharacter = leftCharacters.next(); + const rightCharacter = rightCharacters.next(); + if (leftCharacter.done || rightCharacter.done) return Number(rightCharacter.done) - Number(leftCharacter.done); + const difference = (leftCharacter.value.codePointAt(0) ?? 0) - (rightCharacter.value.codePointAt(0) ?? 0); + if (difference) return difference; + } } export async function removeRecentHistoryResult({ diff --git a/frontend/src/components/FileBrowser/search/types.ts b/frontend/src/components/FileBrowser/search/types.ts index f6d975932..bfc32985a 100644 --- a/frontend/src/components/FileBrowser/search/types.ts +++ b/frontend/src/components/FileBrowser/search/types.ts @@ -24,7 +24,7 @@ import type React from "react"; // ============================================================================ /** Icons understood by the shared quick-bar result renderer. */ -export type SearchResultIcon = "command" | "directory" | "file" | "recent-file"; +export type SearchResultIcon = "command" | "directory" | "recent-directory" | "file" | "recent-file"; /** An inclusive-exclusive text range highlighted by the shared renderer. */ export interface SearchTextHighlight { @@ -46,12 +46,16 @@ export interface SearchResultItem { /** Optional muted result context. */ secondaryText?: string; /** Optional highlighted range in the primary label. */ - primaryHighlight?: SearchTextHighlight; + primaryHighlight?: SearchTextHighlight | SearchTextHighlight[]; /** Optional highlighted range in the secondary label. */ - secondaryHighlight?: SearchTextHighlight; + secondaryHighlight?: SearchTextHighlight | SearchTextHighlight[]; /** Optional keyboard shortcut badge, used by commands. */ shortcutLabel?: string; matchRank?: number; + matchedTermCount?: number; + sortPath?: string; + depth?: number; + lastUsedAt?: string; usageScore?: number; } @@ -67,6 +71,8 @@ export interface SearchResultGroupHeader { /** A structured result rendered by UnifiedSearchBar. */ export type SearchResult = SearchResultItem | SearchResultGroupHeader; +export type SearchResultsUpdate = (results: SearchResult[]) => void; + /** Status information from the provider (e.g., indexing progress) */ export interface SearchStatusInfo { /** Short text label describing current status */ @@ -91,6 +97,8 @@ export type SearchSelectionAction = "associated-viewer" | "force-viewer-picker" export interface SearchProvider { /** Unique identifier for this provider */ id: string; + sourceKey?: string; + invalidateResults?: () => void; /** Stable mode identifier used for UI logic without coupling to translated labels. */ modeId?: string; @@ -116,7 +124,9 @@ export interface SearchProvider { * @param signal AbortSignal for cancellation * @returns Array of search results */ - fetchResults: (query: string, signal: AbortSignal) => Promise; + fetchResults: (query: string, signal: AbortSignal, onUpdate?: SearchResultsUpdate) => Promise; + + getRefreshIntervalMs?: () => number | null; /** * Called when a result is selected (via click or Enter). diff --git a/frontend/src/components/FileBrowser/search/useDirectorySearchProvider.tsx b/frontend/src/components/FileBrowser/search/useDirectorySearchProvider.tsx index e50c5bd3b..ee43d01fe 100644 --- a/frontend/src/components/FileBrowser/search/useDirectorySearchProvider.tsx +++ b/frontend/src/components/FileBrowser/search/useDirectorySearchProvider.tsx @@ -16,23 +16,52 @@ * - Warm-up on activation to trigger cache building */ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { BROWSER_SHORTCUTS } from "../../../config/keyboardShortcuts"; import { type BrowserHistoryService, browserHistoryService } from "../../../services/browserHistoryService"; import { logger } from "../../../services/logger"; import { publishRecentDirectoriesChanged } from "../../../services/recentDirectoriesSync"; -import type { DirectorySearchResult, RecentDirectory } from "../../../types"; +import type { DirectorySearchResult, RecentDirectory, RecentDirectorySearchResponse } from "../../../types"; import { normalizeQuerySeparators } from "./normalizeQuerySeparators"; -import { historyMatchRank, rankHistorySearchResults, removeRecentHistoryResult } from "./recentHistory"; -import type { SearchProvider, SearchResult, SearchResultItem, SearchStatusInfo, SearchTextHighlight } from "./types"; +import { + bestSearchRelevance, + findSearchHighlights, + findTextHighlight, + getSearchRelevance, + rankHistorySearchResults, + removeRecentHistoryResult, + SEARCH_CONTRACT_VERSION, + searchTerms, +} from "./recentHistory"; +import type { SearchProvider, SearchResult, SearchResultItem, SearchResultsUpdate, SearchStatusInfo } from "./types"; // ============================================================================ // Constants // ============================================================================ /** Debounce delay in milliseconds for directory search */ -const DIRECTORY_SEARCH_DEBOUNCE_MS = 200; +const DIRECTORY_SEARCH_DEBOUNCE_MS = 160; +const DIRECTORY_REFRESH_INTERVAL_MS = 500; +const DIRECTORY_RECOVERY_INTERVAL_MS = 5_000; +const DIRECTORY_MIN_RETRY_MS = 1_000; +const DIRECTORY_MAX_RETRY_MS = 60_000; + +function directoryRetryDelay(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) + ? Math.min(DIRECTORY_MAX_RETRY_MS, Math.max(DIRECTORY_MIN_RETRY_MS, value)) + : DIRECTORY_RECOVERY_INTERVAL_MS; +} + +function directoryFailure(error: unknown): { terminal: boolean; delay: number } { + const response = (error as { response?: { status?: number; data?: { detail?: { recoverable?: boolean; retry_after_ms?: number } } } }) + ?.response; + const detail = response?.data?.detail; + return { + terminal: detail?.recoverable === false || [400, 401, 403, 404, 422, 501].includes(response?.status ?? 0), + delay: directoryRetryDelay(detail?.retry_after_ms), + }; +} /** Minimum query length to trigger a directory search */ const DIRECTORY_MIN_QUERY_LENGTH = 2; @@ -114,10 +143,12 @@ function findMatchSegmentRange(segments: string[], query: string): SegmentRange } } - const matchIndex = reconstructed.toLowerCase().indexOf(query.toLowerCase()); - if (matchIndex === -1) return null; - - const matchEnd = matchIndex + query.length - 1; + const highlights = findSearchHighlights(reconstructed, query); + const first = highlights[0]; + const last = highlights.at(-1); + if (!first || !last) return null; + const matchIndex = first.start; + const matchEnd = last.end - 1; const startSeg = charToSegment[matchIndex]; const endSeg = charToSegment[Math.min(matchEnd, charToSegment.length - 1)]; if (startSeg === undefined || endSeg === undefined) return null; @@ -132,12 +163,6 @@ function findMatchSegmentRange(segments: string[], query: string): SegmentRange // SmartPathDisplay — shows path with match context and highlighted match // ============================================================================ -function findTextHighlight(text: string, query: string): SearchTextHighlight | undefined { - if (!query) return undefined; - const start = text.toLocaleLowerCase().indexOf(query.toLocaleLowerCase()); - return start < 0 ? undefined : { start, end: start + query.length }; -} - export function getDirectoryResultPresentation( path: string, query: string @@ -179,6 +204,7 @@ export function getDirectoryResultPresentation( } interface DirectorySearchProviderOptions { + sourceRevision?: number; includeDotDirectories?: boolean; getConnectionName?: (connectionId: string) => string; onNavigateDirectory?: (connectionId: string, path: string, recordId?: string) => void; @@ -209,10 +235,71 @@ export function useDirectorySearchProvider( const [cacheState, setCacheState] = useState("empty"); const [directoryCount, setDirectoryCount] = useState(0); const [totalMatches, setTotalMatches] = useState(0); + const [coverageStatus, setCoverageStatus] = useState | null>(null); + const [sourceFailure, setSourceFailure] = useState<"history" | "index" | null>(null); + const [legacyIndex, setLegacyIndex] = useState(false); + const indexFailureRef = useRef(false); + const indexResponseRef = useRef(null); + const activeIndexQueryRef = useRef(false); + const terminalIndexRef = useRef(false); + const retryDelayRef = useRef(DIRECTORY_RECOVERY_INTERVAL_MS); + const indexResultsRef = useRef<{ query: string; response: DirectorySearchResult } | null>(null); + const recentResponseRef = useRef<{ query: string; limit: number; response: RecentDirectorySearchResponse } | null>(null); const includeDotDirectories = options.includeDotDirectories ?? false; const recentDirectoryResultLimit = options.resultLimit ?? MAX_RECENT_DIRECTORY_RESULT_LIMIT; const history = options.history ?? browserHistoryService; + const sourceRevision = options.sourceRevision ?? 0; const recentDirectoriesRef = useRef(new Map()); + const requestGenerationRef = useRef(0); + const [clientGeneration, setClientGeneration] = useState(0); + const activationControllerRef = useRef(null); + const sourceBindingRef = useRef({ connectionId, history, includeDotDirectories, sourceRevision }); + + const updateIndexStatus = useCallback((response: DirectorySearchResult) => { + setCacheState(response.cache_state); + setDirectoryCount(response.directory_count); + setCoverageStatus((previous) => + previous?.coverage === response.coverage && + previous?.coverage_issue_count === response.coverage_issue_count && + previous?.last_verified_at_ms === response.last_verified_at_ms && + previous?.status_message === response.status_message && + previous?.monitoring_state === response.monitoring_state && + previous?.retry_after_ms === response.retry_after_ms + ? previous + : { + coverage: response.coverage, + coverage_issue_count: response.coverage_issue_count, + last_verified_at_ms: response.last_verified_at_ms, + status_message: response.status_message, + monitoring_state: response.monitoring_state, + retry_after_ms: response.retry_after_ms, + } + ); + }, []); + + useEffect(() => { + if (sourceBindingRef.current.history !== history) { + setClientGeneration((generation) => generation + 1); + } + sourceBindingRef.current = { connectionId, history, includeDotDirectories, sourceRevision }; + recentResponseRef.current = null; + indexResponseRef.current = null; + indexResultsRef.current = null; + terminalIndexRef.current = false; + indexFailureRef.current = false; + setSourceFailure(null); + setCacheState("empty"); + setCoverageStatus(null); + return () => { + requestGenerationRef.current += 1; + recentResponseRef.current = null; + indexResponseRef.current = null; + activationControllerRef.current?.abort(); + }; + }, [connectionId, history, includeDotDirectories, sourceRevision]); const getRecentDirectoryPresentation = useCallback( (directory: RecentDirectory, query: string): Pick => { @@ -230,48 +317,44 @@ export function useDirectorySearchProvider( // fetchResults // const fetchResults = useCallback( - async (query: string, signal: AbortSignal): Promise => { + async (query: string, signal: AbortSignal, onUpdate?: SearchResultsUpdate): Promise => { // Normalise backslashes to forward slashes for cross-directory search const normalizedQuery = normalizeQuerySeparators(query).trim(); - - try { - const recentDirectoriesRequest = history.searchRecentDirectories(normalizedQuery, recentDirectoryResultLimit, signal); - const directorySearchRequest = - normalizedQuery.length >= DIRECTORY_MIN_QUERY_LENGTH - ? history.searchDirectories(connectionId, normalizedQuery, { - includeDotDirectories, - signal, - }) - : Promise.resolve(null); - const [recentResponse, directorySearchResult] = await Promise.all([recentDirectoriesRequest, directorySearchRequest]); - - if (signal.aborted) { - return []; - } - - recentDirectoriesRef.current = new Map(recentResponse.results.map((directory) => [directory.id, directory])); - - if (directorySearchResult) { - setCacheState(directorySearchResult.cache_state); - setDirectoryCount(directorySearchResult.directory_count); - setTotalMatches(directorySearchResult.total_matches); - } - + const terms = searchTerms(normalizedQuery); + const queryIndex = normalizedQuery.length >= DIRECTORY_MIN_QUERY_LENGTH; + activeIndexQueryRef.current = true; + const generation = ++requestGenerationRef.current; + const isCurrent = () => + !signal.aborted && + generation === requestGenerationRef.current && + sourceBindingRef.current.connectionId === connectionId && + sourceBindingRef.current.history === history && + sourceBindingRef.current.sourceRevision === sourceRevision && + sourceBindingRef.current.includeDotDirectories === includeDotDirectories; + if (!isCurrent()) return []; + setSourceFailure(indexFailureRef.current ? "index" : null); + let recentResponse: RecentDirectorySearchResponse = { results: [], result_limit: recentDirectoryResultLimit }; + let directorySearchResult = indexResultsRef.current?.query === normalizedQuery ? indexResultsRef.current.response : null; + + const mergeResults = (): SearchResult[] => { const recentPaths = new Set(recentResponse.results.map((directory) => `${directory.connection_id}\u0000${directory.path}`)); const directoryResults = (directorySearchResult?.results ?? []) .filter((path) => !recentPaths.has(`${connectionId}\u0000${path}`)) - .slice(0, recentResponse.result_limit) .map((path) => ({ kind: "result" as const, id: `${DIRECTORY_PREFIX}${path}`, value: `${DIRECTORY_PREFIX}${path}`, icon: "directory" as const, - matchRank: - directorySearchResult?.match_ranks?.[path] ?? historyMatchRank(path.split(PATH_SEPARATOR).at(-1) ?? path, normalizedQuery), + ...bestSearchRelevance(terms.length, getSearchRelevance(path, terms, path.split(PATH_SEPARATOR).at(-1) ?? path), { + matchedTermCount: directorySearchResult?.matched_term_counts?.[path], + matchRank: directorySearchResult?.match_ranks?.[path], + }), + sortPath: path, + depth: path.split(PATH_SEPARATOR).length - 1, ...getDirectoryResultPresentation(path, normalizedQuery), })); - return rankHistorySearchResults( + const ranked = rankHistorySearchResults( [ ...(recentResponse.results.length > 0 ? [ @@ -287,9 +370,20 @@ export function useDirectorySearchProvider( kind: "result" as const, id: `${RECENT_DIRECTORY_PREFIX}${directory.id}`, value: `${RECENT_DIRECTORY_PREFIX}${directory.id}`, - icon: "directory" as const, - matchRank: - directory.match_rank ?? historyMatchRank(directory.path.split(PATH_SEPARATOR).at(-1) ?? directory.path, normalizedQuery), + icon: "recent-directory" as const, + ...bestSearchRelevance( + terms.length, + getSearchRelevance(directory.path, terms, directory.path.split(PATH_SEPARATOR).at(-1) ?? directory.path), + { matchedTermCount: directory.matched_term_count, matchRank: directory.match_rank }, + directory.connection_id === connectionId + ? { + matchedTermCount: directorySearchResult?.matched_term_counts?.[directory.path], + matchRank: directorySearchResult?.match_ranks?.[directory.path], + } + : {} + ), + sortPath: directory.path, + lastUsedAt: directory.last_visited_at, usageScore: directory.usage_score, ...getRecentDirectoryPresentation(directory, normalizedQuery), })), @@ -307,17 +401,58 @@ export function useDirectorySearchProvider( ], normalizedQuery ); - } catch (error: unknown) { - if ( - signal.aborted || - (error && typeof error === "object" && "code" in error && (error as { code?: string }).code === "ERR_CANCELED") - ) { - return []; - } - throw error; + let selectedCount = 0; + return ranked.filter((row) => row.kind === "group-header" || selectedCount++ < recentResponse.result_limit); + }; + + const cachedHistory = recentResponseRef.current; + const recentPromise = + cachedHistory?.query === normalizedQuery && cachedHistory.limit === recentDirectoryResultLimit + ? Promise.resolve(cachedHistory.response) + : history.searchRecentDirectories(normalizedQuery, recentDirectoryResultLimit, signal); + const recentRequest = recentPromise.then((response) => { + if (!isCurrent()) return; + recentResponse = response; + recentResponseRef.current = { query: normalizedQuery, limit: recentDirectoryResultLimit, response }; + recentDirectoriesRef.current = new Map(response.results.map((directory) => [directory.id, directory])); + onUpdate?.(mergeResults()); + }); + const indexRequest = !terminalIndexRef.current + ? history.searchDirectories(connectionId, queryIndex ? normalizedQuery : "", { includeDotDirectories, signal }).then((response) => { + if (!isCurrent()) return; + directorySearchResult = queryIndex ? response : null; + indexResultsRef.current = queryIndex ? { query: normalizedQuery, response } : null; + indexResponseRef.current = response; + indexFailureRef.current = false; + terminalIndexRef.current = + response.search_contract_version != null && response.search_contract_version !== SEARCH_CONTRACT_VERSION; + setLegacyIndex(terms.length > 1 && response.search_contract_version !== SEARCH_CONTRACT_VERSION); + updateIndexStatus(response); + setTotalMatches(response.total_matches); + onUpdate?.(mergeResults()); + }) + : Promise.resolve(); + const [recentOutcome, indexOutcome] = await Promise.allSettled([recentRequest, indexRequest]); + if (!isCurrent()) return []; + if (indexOutcome.status === "rejected") { + indexFailureRef.current = true; + const failure = directoryFailure(indexOutcome.reason); + terminalIndexRef.current = failure.terminal; + retryDelayRef.current = failure.delay; } + setSourceFailure(indexFailureRef.current ? "index" : recentOutcome.status === "rejected" ? "history" : null); + return mergeResults(); }, - [connectionId, getRecentDirectoryPresentation, history, includeDotDirectories, recentDirectoryResultLimit, t] + [ + connectionId, + getRecentDirectoryPresentation, + history, + includeDotDirectories, + recentDirectoryResultLimit, + sourceRevision, + t, + updateIndexStatus, + ] ); // @@ -342,6 +477,7 @@ export function useDirectorySearchProvider( const onRemoveSelected = useCallback( async (value: string) => { + recentResponseRef.current = null; return removeRecentHistoryResult({ value, prefix: RECENT_DIRECTORY_PREFIX, @@ -357,49 +493,102 @@ export function useDirectorySearchProvider( // getStatusInfo // const getStatusInfo = useCallback((): SearchStatusInfo | null => { + const working = + (cacheState === "building" || cacheState === "updating") && !(coverageStatus?.retry_after_ms && coverageStatus.retry_after_ms > 0); + if (sourceFailure) { + return { + label: t(`fileBrowser.search.status.${sourceFailure}Unavailable`), + showSpinner: false, + }; + } + if (coverageStatus?.status_message) return { label: coverageStatus.status_message, showSpinner: !!working }; + if (coverageStatus?.coverage === "uncertain") + return { + label: t("fileBrowser.search.status.indexCoverageUncertain"), + showSpinner: !!working, + }; + if (coverageStatus?.coverage === "partial" && cacheState === "ready") + return { label: t("fileBrowser.search.status.indexCoveragePartial"), showSpinner: false }; + if (legacyIndex) return { label: t("fileBrowser.search.status.legacyDirectorySearch"), showSpinner: false }; switch (cacheState) { case "building": return { label: t("fileBrowser.search.status.indexing", { count: directoryCount }), - showSpinner: true, + showSpinner: !!working, }; case "updating": return { label: t("fileBrowser.search.status.updating", { count: directoryCount }), - showSpinner: true, + showSpinner: !!working, }; case "empty": - return { label: t("fileBrowser.search.status.startingIndex"), showSpinner: true }; + return { label: t("fileBrowser.search.status.startingIndex"), showSpinner: false }; case "ready": return null; default: return null; } - }, [cacheState, directoryCount, t]); + }, [cacheState, coverageStatus, directoryCount, legacyIndex, sourceFailure, t]); + + const getRefreshIntervalMs = useCallback((): number | null => { + const response = indexResponseRef.current; + if (!activeIndexQueryRef.current || terminalIndexRef.current) return null; + if (indexFailureRef.current) return retryDelayRef.current; + if (!response || response.search_contract_version !== SEARCH_CONTRACT_VERSION) return null; + if (response.retry_after_ms != null && response.retry_after_ms > 0) return directoryRetryDelay(response.retry_after_ms); + if (response.cache_state === "building" || response.cache_state === "updating") return DIRECTORY_REFRESH_INTERVAL_MS; + return response.cache_state === "empty" || + response.status_message || + response.coverage === "uncertain" || + (response.monitoring_state != null && response.monitoring_state !== "healthy") + ? DIRECTORY_RECOVERY_INTERVAL_MS + : null; + }, []); // // onActivate // const onActivate = useCallback(() => { // Trigger an empty search to warm up the directory cache + activationControllerRef.current?.abort(); const controller = new AbortController(); + activeIndexQueryRef.current = true; + terminalIndexRef.current = false; + const generation = requestGenerationRef.current; + activationControllerRef.current = controller; history .searchDirectories(connectionId, "", { includeDotDirectories, signal: controller.signal, }) .then((result: DirectorySearchResult) => { - if (!controller.signal.aborted) { - setCacheState(result.cache_state); - setDirectoryCount(result.directory_count); + if (!controller.signal.aborted && generation === requestGenerationRef.current) { + indexResponseRef.current = result; + indexFailureRef.current = false; + setSourceFailure(null); + terminalIndexRef.current = result.search_contract_version != null && result.search_contract_version !== SEARCH_CONTRACT_VERSION; + updateIndexStatus(result); } }) .catch((error: unknown) => { - if (error && typeof error === "object" && "code" in error && (error as { code?: string }).code !== "ERR_CANCELED") { - logger.error("Failed to warm up directory cache", { error }, "directory-search-provider"); + if (!controller.signal.aborted && generation === requestGenerationRef.current) { + const failure = directoryFailure(error); + terminalIndexRef.current = failure.terminal; + retryDelayRef.current = failure.delay; + indexFailureRef.current = true; + setSourceFailure("index"); + logger.warn("Directory cache activation is unavailable", {}, "directory-search-provider"); } }); - }, [connectionId, history, includeDotDirectories]); + }, [connectionId, history, includeDotDirectories, updateIndexStatus]); + + const onDeactivate = useCallback(() => { + activeIndexQueryRef.current = false; + requestGenerationRef.current += 1; + recentResponseRef.current = null; + activationControllerRef.current?.abort(); + activationControllerRef.current = null; + }, []); // // footerInfo @@ -423,6 +612,7 @@ export function useDirectorySearchProvider( return { id: "directory-search", + sourceKey: `${connectionId}:${includeDotDirectories}:${recentDirectoryResultLimit}:${clientGeneration}:${sourceRevision}`, modeId: "navigate", modeLabel: t("fileBrowser.search.modes.navigate"), placeholder: t("fileBrowser.search.placeholders.directory"), @@ -432,7 +622,12 @@ export function useDirectorySearchProvider( onSelect, onRemoveSelected, getStatusInfo, + getRefreshIntervalMs, + invalidateResults: () => { + recentResponseRef.current = null; + }, onActivate, + onDeactivate, getFooterHint: (selectedResult) => ( <> ↑↓ {t("fileBrowser.search.footer.navigate")} ↵ {t("fileBrowser.search.footer.open")} esc{" "} diff --git a/frontend/src/components/FileBrowser/search/useFileSearchProvider.tsx b/frontend/src/components/FileBrowser/search/useFileSearchProvider.tsx index 848837690..e52ec1dd9 100644 --- a/frontend/src/components/FileBrowser/search/useFileSearchProvider.tsx +++ b/frontend/src/components/FileBrowser/search/useFileSearchProvider.tsx @@ -1,11 +1,20 @@ -import { useCallback, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import type { BrowserOpenMode } from "../../../pages/FileBrowser/types"; import { type BrowserHistoryService, browserHistoryService } from "../../../services/browserHistoryService"; import { publishRecentFilesChanged } from "../../../services/recentFilesSync"; -import type { FileEntry, RecentFile } from "../../../types"; -import { historyMatchRank, normalizeSearchText, rankHistorySearchResults, removeRecentHistoryResult } from "./recentHistory"; -import type { SearchProvider, SearchResult, SearchSelectionAction, SearchStatusInfo, SearchTextHighlight } from "./types"; +import type { FileEntry, RecentFile, RecentFileSearchResponse } from "../../../types"; +import { + bestSearchRelevance, + compareText, + findTextHighlight, + getSearchRelevance, + normalizeSearchText, + rankHistorySearchResults, + removeRecentHistoryResult, + searchTerms, +} from "./recentHistory"; +import type { SearchProvider, SearchResult, SearchResultsUpdate, SearchSelectionAction, SearchStatusInfo } from "./types"; const CURRENT_DIRECTORY_PREFIX = "current:"; const RECENT_FILE_PREFIX = "recent:"; @@ -24,12 +33,6 @@ function formatConnectionPath(connection: string, path: string): string { return `${connection}:${rootedPath}`; } -function findTextHighlight(text: string, query: string): SearchTextHighlight | undefined { - if (!query) return undefined; - const start = text.toLocaleLowerCase().indexOf(query.toLocaleLowerCase()); - return start < 0 ? undefined : { start, end: start + query.length }; -} - interface FileSearchProviderOptions { connectionId: string; currentPath: string; @@ -56,71 +59,128 @@ export function useFileSearchProvider({ const { t } = useTranslation(); const recentFilesRef = useRef(new Map()); const currentFilesRef = useRef(new Map()); + const requestGenerationRef = useRef(0); + const sourceBindingRef = useRef({ connectionId, currentPath, files, history, resultLimit }); + const [historyUnavailable, setHistoryUnavailable] = useState(false); + + useEffect(() => { + sourceBindingRef.current = { connectionId, currentPath, files, history, resultLimit }; + return () => { + requestGenerationRef.current += 1; + recentFilesRef.current.clear(); + currentFilesRef.current.clear(); + }; + }, [connectionId, currentPath, files, history, resultLimit]); const fetchResults = useCallback( - async (query: string, signal: AbortSignal): Promise => { - const normalizedQuery = normalizeSearchText(query.trim()); + async (query: string, signal: AbortSignal, onUpdate?: SearchResultsUpdate): Promise => { + const generation = ++requestGenerationRef.current; + const isCurrent = () => + !signal.aborted && + generation === requestGenerationRef.current && + sourceBindingRef.current.connectionId === connectionId && + sourceBindingRef.current.currentPath === currentPath && + sourceBindingRef.current.files === files && + sourceBindingRef.current.history === history && + sourceBindingRef.current.resultLimit === resultLimit; + if (!isCurrent()) return []; + setHistoryUnavailable(false); + const terms = searchTerms(query); const currentCandidates = files - .filter((file) => file.type === "file" && (!normalizedQuery || normalizeSearchText(file.name).includes(normalizedQuery))) + .filter((file) => file.type === "file" && (!terms.length || getSearchRelevance(file.name, terms).matchedTermCount > 0)) .sort((left, right) => { - return historyMatchRank(left.name, normalizedQuery) - historyMatchRank(right.name, normalizedQuery); + const leftRank = getSearchRelevance(left.name, terms); + const rightRank = getSearchRelevance(right.name, terms); + return ( + rightRank.matchedTermCount - leftRank.matchedTermCount || + leftRank.matchRank - rightRank.matchRank || + compareText(normalizeSearchText(left.name), normalizeSearchText(right.name)) || + compareText(left.name, right.name) + ); }); - const recentResponse = await history.searchRecentFiles(query, resultLimit, signal); - const effectiveResultLimit = recentResponse.result_limit ?? resultLimit; - const recentPaths = new Set(recentResponse.results.map((file) => `${file.connection_id}\u0000${file.path}`)); - const uniqueCurrentMatches = currentCandidates - .filter((file) => !recentPaths.has(`${connectionId}\u0000${currentPath ? `${currentPath}/` : ""}${file.name}`)) - .slice(0, effectiveResultLimit); + const mergeResults = (recentResponse: RecentFileSearchResponse): SearchResult[] => { + const effectiveResultLimit = recentResponse.result_limit ?? resultLimit; + const currentRelevance = new Map( + currentCandidates.map((file) => [ + `${connectionId}\u0000${currentPath ? `${currentPath}/` : ""}${file.name}`, + getSearchRelevance(file.name, terms), + ]) + ); + const recentPaths = new Set(recentResponse.results.map((file) => `${file.connection_id}\u0000${file.path}`)); + const uniqueCurrentMatches = currentCandidates + .filter((file) => !recentPaths.has(`${connectionId}\u0000${currentPath ? `${currentPath}/` : ""}${file.name}`)) + .slice(0, effectiveResultLimit); - recentFilesRef.current = new Map(recentResponse.results.map((file) => [file.id, file])); - currentFilesRef.current = new Map(uniqueCurrentMatches.map((file) => [file.name, file])); + recentFilesRef.current = new Map(recentResponse.results.map((file) => [file.id, file])); + currentFilesRef.current = new Map(uniqueCurrentMatches.map((file) => [file.name, file])); - return rankHistorySearchResults( - [ - ...(recentResponse.results.length > 0 - ? [ - { - id: "recent-group-header", - kind: "group-header" as const, - value: "" as const, - label: t("fileBrowser.search.groups.recentFiles"), - }, - ] - : []), - ...recentResponse.results.map((file) => ({ - kind: "result" as const, - id: `${RECENT_FILE_PREFIX}${file.id}`, - value: `${RECENT_FILE_PREFIX}${file.id}`, - icon: "recent-file" as const, - matchRank: file.match_rank ?? historyMatchRank(file.file_name, query), - usageScore: file.usage_score, - primaryText: file.file_name, - primaryHighlight: findTextHighlight(file.file_name, query.trim()), - secondaryText: formatConnectionPath(getConnectionName(file.connection_id), parentPath(file.path)), - })), - ...(uniqueCurrentMatches.length > 0 - ? [ - { - id: "current-directory-group-header", - kind: "group-header" as const, - value: "" as const, - label: t("fileBrowser.search.groups.currentDirectory"), - }, - ] - : []), - ...uniqueCurrentMatches.map((file) => ({ - kind: "result" as const, - id: `${CURRENT_DIRECTORY_PREFIX}${file.name}`, - value: `${CURRENT_DIRECTORY_PREFIX}${file.name}`, - icon: "file" as const, - matchRank: historyMatchRank(file.name, query), - primaryText: file.name, - primaryHighlight: findTextHighlight(file.name, query.trim()), - secondaryText: formatConnectionPath(connectionName, currentPath || "/"), - })), - ], - query - ); + return rankHistorySearchResults( + [ + ...(recentResponse.results.length > 0 + ? [ + { + id: "recent-group-header", + kind: "group-header" as const, + value: "" as const, + label: t("fileBrowser.search.groups.recentFiles"), + }, + ] + : []), + ...recentResponse.results.map((file) => ({ + kind: "result" as const, + id: `${RECENT_FILE_PREFIX}${file.id}`, + value: `${RECENT_FILE_PREFIX}${file.id}`, + icon: "recent-file" as const, + ...bestSearchRelevance( + terms.length, + getSearchRelevance(file.file_name, terms), + { matchedTermCount: file.matched_term_count, matchRank: file.match_rank }, + currentRelevance.get(`${file.connection_id}\u0000${file.path}`) ?? {} + ), + usageScore: file.usage_score, + sortPath: file.file_name, + lastUsedAt: file.last_opened_at, + primaryText: file.file_name, + primaryHighlight: findTextHighlight(file.file_name, query.trim()), + secondaryText: formatConnectionPath(getConnectionName(file.connection_id), parentPath(file.path)), + })), + ...(uniqueCurrentMatches.length > 0 + ? [ + { + id: "current-directory-group-header", + kind: "group-header" as const, + value: "" as const, + label: t("fileBrowser.search.groups.currentDirectory"), + }, + ] + : []), + ...uniqueCurrentMatches.map((file) => ({ + kind: "result" as const, + id: `${CURRENT_DIRECTORY_PREFIX}${file.name}`, + value: `${CURRENT_DIRECTORY_PREFIX}${file.name}`, + icon: "file" as const, + ...getSearchRelevance(file.name, terms), + sortPath: file.name, + primaryText: file.name, + primaryHighlight: findTextHighlight(file.name, query.trim()), + secondaryText: formatConnectionPath(connectionName, currentPath || "/"), + })), + ], + query + ); + }; + const availableResponse: RecentFileSearchResponse = { results: [], result_limit: resultLimit }; + const availableResults = mergeResults(availableResponse); + onUpdate?.(availableResults); + try { + const recentResponse = await history.searchRecentFiles(query, resultLimit, signal); + if (!isCurrent()) return []; + return mergeResults(recentResponse); + } catch { + if (!isCurrent()) return []; + setHistoryUnavailable(true); + return availableResults; + } }, [connectionId, connectionName, currentPath, files, getConnectionName, history, resultLimit, t] ); @@ -139,7 +199,11 @@ export function useFileSearchProvider({ [onOpenCurrentFile, onOpenRecentFile] ); - const getStatusInfo = useCallback((): SearchStatusInfo | null => null, []); + const getStatusInfo = useCallback( + (): SearchStatusInfo | null => + historyUnavailable ? { label: t("fileBrowser.search.status.fileHistoryUnavailable"), showSpinner: false } : null, + [historyUnavailable, t] + ); const onRemoveSelected = useCallback( async (value: string) => { @@ -156,6 +220,7 @@ export function useFileSearchProvider({ return { id: "file-search", + sourceKey: `${connectionId}:${currentPath}:${resultLimit}`, modeId: "file-search", modeLabel: t("fileBrowser.search.modes.fileSearch"), placeholder: t("fileBrowser.search.placeholders.fileSearch"), diff --git a/frontend/src/components/Settings/CompanionPolicyDelivery.tsx b/frontend/src/components/Settings/CompanionPolicyDelivery.tsx new file mode 100644 index 000000000..6cc827fe2 --- /dev/null +++ b/frontend/src/components/Settings/CompanionPolicyDelivery.tsx @@ -0,0 +1,231 @@ +import { Refresh as RefreshIcon, Sync as SyncIcon } from "@mui/icons-material"; +import { Alert, Box, Button, Stack } from "@mui/material"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useCachedAsyncData } from "../../hooks/useCachedAsyncData"; +import api from "../../services/api"; +import companionService from "../../services/companion"; +import { type CompanionDirectoryPolicyStatus, type DirectoryIndexSettings, isApiError } from "../../types"; +import { getApiErrorMessage } from "../../utils/apiErrors"; +import { SettingsGroup } from "./SettingsGroup"; +import { SettingsLoadingState } from "./SettingsState"; +import { settingsUtilityButtonSx } from "./settingsButtonStyles"; +import { SETTINGS_DATA_CACHE_KEYS } from "./settingsDataSources"; + +const POLICY_APPROVAL_POLL_MS = 500; +const POLICY_APPROVAL_TIMEOUT_MS = 110_000; +const loadDesiredPolicy = () => api.getDirectoryIndexSettings("companion"); + +export function CompanionDirectoryStatus({ isAdmin }: { isAdmin: boolean }) { + const { t } = useTranslation(); + const [loadError, setLoadError] = useState(null); + const handleLoadError = useCallback( + (error: unknown) => setLoadError(getApiErrorMessage(error, t("settings.directorySearch.loadFailed"))), + [t] + ); + const { data, loading, refresh } = useCachedAsyncData({ + cacheKey: SETTINGS_DATA_CACHE_KEYS.directoryIndexCompanion, + load: loadDesiredPolicy, + enabled: isAdmin, + refreshCachedDataOnMount: true, + onError: handleLoadError, + }); + const desired = isAdmin ? data : null; + return ( + + {isAdmin && loadError && !desired && ( + { + setLoadError(null); + void refresh(true); + }} + > + {t("common.actions.retry")} + + } + > + {loadError} + + )} + {isAdmin && loading && !desired ? ( + + ) : ( + + )} + + ); +} + +export function CompanionPolicyDelivery({ desiredRevision, backendIdentity }: { desiredRevision?: number; backendIdentity?: string }) { + const { t } = useTranslation(); + const canApply = desiredRevision !== undefined && backendIdentity !== undefined; + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const [unavailable, setUnavailable] = useState<"offline" | "legacy" | "authorization" | null>(null); + const [busy, setBusy] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const controller = useRef(null); + const setFailure = useCallback( + (failure: unknown) => { + if (failure instanceof Error && failure.name === "Error") { + setUnavailable(null); + setError(failure.message); + return; + } + const status = isApiError(failure) ? failure.response?.status : undefined; + setUnavailable(status === 404 ? "legacy" : status === 401 || status === 403 ? "authorization" : "offline"); + setError(getApiErrorMessage(failure, t("settings.directorySearch.unavailable"))); + }, + [t] + ); + const refresh = useCallback(async () => { + if (controller.current) return; + const request = new AbortController(); + controller.current = request; + setBusy(true); + setRefreshing(true); + setError(null); + try { + const result = await companionService.getDirectoryPolicyStatus({ signal: request.signal }); + if (!request.signal.aborted) { + setStatus(result); + setUnavailable(null); + } + } catch (failure) { + if (!request.signal.aborted) setFailure(failure); + } finally { + if (controller.current === request) { + controller.current = null; + if (!request.signal.aborted) { + setBusy(false); + setRefreshing(false); + } + } + } + }, [setFailure]); + useEffect(() => { + setStatus((current) => + current && current.backend_identity === backendIdentity && current.effective_revision === desiredRevision ? current : null + ); + setUnavailable(null); + void refresh(); + return () => { + controller.current?.abort(); + controller.current = null; + }; + }, [refresh, desiredRevision, backendIdentity]); + const apply = async () => { + if (controller.current || !canApply) return; + const request = new AbortController(); + controller.current = request; + setBusy(true); + setError(null); + try { + let challenge = await companionService.requestDirectoryPolicyChallenge({ signal: request.signal }); + if (request.signal.aborted) return; + setStatus(challenge); + const deadline = Date.now() + POLICY_APPROVAL_TIMEOUT_MS; + const expected = challenge.challenge; + while (!challenge.approved) { + if (request.signal.aborted) return; + if (Date.now() >= deadline || challenge.status === "denied" || !expected || challenge.challenge !== expected) + throw new Error(t("settings.directorySearch.approvalExpired")); + await new Promise((resolve) => { + const finish = () => { + clearTimeout(timeout); + request.signal.removeEventListener("abort", finish); + resolve(); + }; + const timeout = setTimeout(finish, POLICY_APPROVAL_POLL_MS); + request.signal.addEventListener("abort", finish, { once: true }); + }); + if (request.signal.aborted) return; + challenge = await companionService.getDirectoryPolicyStatus({ signal: request.signal }); + if (request.signal.aborted) return; + setStatus(challenge); + } + if (!challenge.challenge || challenge.status === "owner_conflict") throw new Error(t("settings.directorySearch.ownerConflict")); + const grant = await api.issueDirectoryPolicyGrant( + { companion_id: challenge.companion_id, challenge: challenge.challenge, origin: window.location.origin }, + { signal: request.signal } + ); + if (request.signal.aborted) return; + const applied = await companionService.redeemDirectoryPolicyGrant(grant.grant, { signal: request.signal }); + if (request.signal.aborted) return; + if ( + applied.status !== "applied" || + applied.effective_revision !== grant.revision || + applied.backend_identity !== grant.backend_identity || + applied.owner_origin !== window.location.origin + ) + throw new Error(t("settings.directorySearch.confirmationMismatch")); + setStatus(applied); + setUnavailable(null); + } catch (failure) { + if (!request.signal.aborted) setFailure(failure); + } finally { + if (controller.current === request) { + controller.current = null; + if (!request.signal.aborted) setBusy(false); + } + } + }; + const conflict = + status?.status === "owner_conflict" || + Boolean(backendIdentity && status?.backend_identity && status.backend_identity !== backendIdentity) || + Boolean(status?.owner_origin && status.owner_origin !== window.location.origin); + const applied = !conflict && status?.status === "applied" && (!canApply || status.effective_revision === desiredRevision); + const message = + unavailable === "legacy" + ? t("settings.directorySearch.legacy") + : unavailable === "authorization" + ? t("settings.directorySearch.authorization") + : conflict + ? t("settings.directorySearch.ownerConflict") + : busy + ? refreshing + ? t("settings.directorySearch.refreshing") + : status?.status === "pending_local_approval" + ? t("settings.directorySearch.waitingApproval") + : t("settings.directorySearch.delivering") + : applied + ? t("settings.directorySearch.active") + : unavailable === "offline" + ? t("settings.directorySearch.offline") + : t(canApply ? "settings.directorySearch.pending" : "settings.directorySearch.notApplied"); + return ( + + + {unavailable ? message : (error ?? status?.message ?? message)} + + + {canApply && t("settings.directorySearch.desiredRevision", { revision: desiredRevision })}{" "} + {t("settings.directorySearch.activeRevision", { revision: status?.effective_revision ?? t("settings.directorySearch.noRevision") })} + + + {canApply && ( + + )} + + + + ); +} diff --git a/frontend/src/components/Settings/IntegerSettingFields.tsx b/frontend/src/components/Settings/IntegerSettingFields.tsx new file mode 100644 index 000000000..25c943e10 --- /dev/null +++ b/frontend/src/components/Settings/IntegerSettingFields.tsx @@ -0,0 +1,338 @@ +import { Box, FormControl, InputAdornment, MenuItem, Stack, TextField } from "@mui/material"; +import { useEffect, useId, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { SettingPersistenceIndicator } from "../../hooks/useSystemSettingPersistence"; +import { translate } from "../../i18n"; +import type { IntegerSystemSetting } from "../../types"; +import { formatLocalizedNumber } from "../../utils/localeFormatting"; +import { SettingsFieldHelp } from "./SettingsFieldHelp"; + +const DESKTOP_FIELD_ROW_MAX_WIDTH = 440; +const DESKTOP_VALUE_FIELD_MAX_WIDTH = 220; +const DESKTOP_UNIT_FIELD_WIDTH = 120; +const DESKTOP_NUMERIC_FIELD_MAX_WIDTH = 240; + +const BYTE_UNITS = [ + { label: "B", factor: 1 }, + { label: "KiB", factor: 1024 }, + { label: "MiB", factor: 1024 * 1024 }, + { label: "GiB", factor: 1024 * 1024 * 1024 }, +] as const; + +type ByteUnitLabel = (typeof BYTE_UNITS)[number]["label"]; + +function formatInteger(value: number): string { + return formatLocalizedNumber(value); +} + +function formatByteSize(bytes: number): string { + const matchingUnit = [...BYTE_UNITS].reverse().find((unit) => bytes >= unit.factor && bytes % unit.factor === 0) ?? BYTE_UNITS[0]; + return `${formatInteger(bytes / matchingUnit.factor)} ${matchingUnit.label}`; +} + +function formatBytesWithExactValue(bytes: number): string { + return `${formatByteSize(bytes)} (${formatInteger(bytes)} bytes)`; +} + +function getPreferredByteUnit(bytes: number): ByteUnitLabel { + return ([...BYTE_UNITS].reverse().find((unit) => bytes >= unit.factor && bytes % unit.factor === 0) ?? BYTE_UNITS[0]).label; +} + +function getByteUnitFactor(unitLabel: ByteUnitLabel): number { + return BYTE_UNITS.find((unit) => unit.label === unitLabel)?.factor ?? 1; +} + +function validateIntegerSetting(setting: IntegerSystemSetting, value: number | null, unitLabel?: string): string | null { + if (value === null) { + return translate("settings.advanced.validation.enterLabel", { label: setting.label }); + } + + if (!Number.isInteger(value)) { + return translate("settings.advanced.validation.wholeNumber", { label: setting.label }); + } + + if (value < setting.min_value || value > setting.max_value) { + if (unitLabel) { + return translate("settings.advanced.validation.betweenRangeWithUnit", { + label: setting.label, + min: formatInteger(setting.min_value), + max: formatInteger(setting.max_value), + unit: unitLabel, + }); + } + + return translate("settings.advanced.validation.betweenRange", { + label: setting.label, + min: formatInteger(setting.min_value), + max: formatInteger(setting.max_value), + }); + } + + return null; +} + +function validateByteSizeSetting(setting: IntegerSystemSetting, value: number | null): string | null { + if (value === null) { + return translate("settings.advanced.validation.enterLabel", { label: setting.label }); + } + + if (!Number.isInteger(value)) { + return translate("settings.advanced.validation.wholeNumber", { label: setting.label }); + } + + if (value < setting.min_value || value > setting.max_value) { + return translate("settings.advanced.validation.betweenRange", { + label: setting.label, + min: formatByteSize(setting.min_value), + max: formatByteSize(setting.max_value), + }); + } + + return null; +} + +export function SettingField({ + setting, + value, + onChange, + onCommit, + persistenceError, + pending, + saved, + unitAdornment, +}: { + setting: IntegerSystemSetting; + value: number | null; + onChange: (value: number | null) => void; + onCommit: (value: number) => void; + persistenceError?: string; + pending: boolean; + saved: boolean; + unitAdornment?: string; +}) { + const { t } = useTranslation(); + const helperId = useId(); + const [displayValue, setDisplayValue] = useState(value === null ? "" : String(value)); + const [touched, setTouched] = useState(false); + + useEffect(() => { + setDisplayValue(value === null ? "" : String(value)); + }, [value]); + + const validationError = validateIntegerSetting(setting, value, unitAdornment); + const errorText = persistenceError ?? (touched ? validationError : null); + const helperText = errorText + ? errorText + : t("settings.advanced.helperText.integer", { + description: setting.description, + defaultValue: setting.default_value, + minValue: setting.min_value, + maxValue: setting.max_value, + }); + + return ( + + { + const nextValue = event.target.value; + if (!/^\d*$/.test(nextValue)) { + return; + } + + setDisplayValue(nextValue); + onChange(nextValue ? Number(nextValue) : null); + }} + onBlur={() => { + setTouched(true); + if (!validationError && value !== null) onCommit(value); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + setTouched(true); + if (!validationError && value !== null) onCommit(value); + } + }} + variant="outlined" + sx={{ width: "100%", maxWidth: { xs: "100%", sm: DESKTOP_NUMERIC_FIELD_MAX_WIDTH } }} + disabled={pending} + error={Boolean(errorText)} + slotProps={{ + htmlInput: { inputMode: "numeric", pattern: "[0-9]*", "aria-describedby": helperId }, + input: + unitAdornment || pending || saved + ? { + endAdornment: ( + + {unitAdornment} + + + ), + } + : undefined, + }} + /> + + {helperText} + + + ); +} + +export function ByteSizeSettingField({ + setting, + fixedUnit, + value, + onChange, + onCommit, + persistenceError, + pending, + saved, +}: { + setting: IntegerSystemSetting; + fixedUnit?: ByteUnitLabel; + value: number | null; + onChange: (value: number | null) => void; + onCommit: (value: number) => void; + persistenceError?: string; + pending: boolean; + saved: boolean; +}) { + const { t } = useTranslation(); + const helperId = useId(); + const [unit, setUnit] = useState(() => fixedUnit ?? getPreferredByteUnit(value ?? setting.min_value)); + const [displayValue, setDisplayValue] = useState(() => + value === null ? "" : String(value / getByteUnitFactor(fixedUnit ?? getPreferredByteUnit(value))) + ); + const [touched, setTouched] = useState(false); + + useEffect(() => { + if (value === null) { + setDisplayValue(""); + return; + } + + const factor = getByteUnitFactor(unit); + if (!fixedUnit && factor > value && value > 0) { + const nextUnit = getPreferredByteUnit(value); + setUnit(nextUnit); + setDisplayValue(String(value / getByteUnitFactor(nextUnit))); + return; + } + + setDisplayValue(String(value / factor)); + }, [fixedUnit, unit, value]); + + const factor = getByteUnitFactor(unit); + const validationError = + validateByteSizeSetting(setting, value) ?? + (fixedUnit && value !== null && value % setting.step !== 0 ? `${setting.label} must use whole ${fixedUnit} increments` : null); + const errorText = persistenceError ?? (touched ? validationError : null); + + const handleValueChange = (nextValue: string) => { + if (!/^\d*$/.test(nextValue)) { + return; + } + + setDisplayValue(nextValue); + + if (!nextValue) { + onChange(null); + return; + } + + onChange(Number(nextValue) * factor); + }; + + const handleValueBlur = () => { + setTouched(true); + if (!validationError && value !== null) onCommit(value); + }; + + const handleUnitChange = (nextUnit: ByteUnitLabel) => { + setUnit(nextUnit); + }; + + const availableUnits = BYTE_UNITS.filter((option) => + fixedUnit ? option.label === fixedUnit : value === null || value % option.factor === 0 || option.label === unit + ); + const helperMessage = errorText + ? errorText + : t("settings.advanced.helperText.byteSize", { + description: setting.description, + defaultValue: formatBytesWithExactValue(setting.default_value), + minValue: formatByteSize(setting.min_value), + maxValue: formatByteSize(setting.max_value), + }); + + return ( + + + + handleValueChange(event.target.value)} + onBlur={handleValueBlur} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + handleValueBlur(); + } + }} + slotProps={{ + htmlInput: { inputMode: "numeric", pattern: "[0-9]*", "aria-describedby": helperId }, + input: + pending || saved + ? { + endAdornment: ( + + + + ), + } + : undefined, + }} + variant="outlined" + sx={{ width: "100%", maxWidth: { xs: "100%", sm: DESKTOP_VALUE_FIELD_MAX_WIDTH } }} + disabled={pending} + error={Boolean(errorText)} + /> + handleUnitChange(event.target.value as ByteUnitLabel)} + variant="outlined" + disabled={pending || Boolean(fixedUnit)} + error={Boolean(errorText)} + sx={{ width: { xs: "100%", sm: DESKTOP_UNIT_FIELD_WIDTH } }} + > + {availableUnits.map((option) => ( + + {option.label} + + ))} + + + + {helperMessage} + + + + ); +} diff --git a/frontend/src/components/Settings/SettingsCategoryContent.tsx b/frontend/src/components/Settings/SettingsCategoryContent.tsx index 217ed4f17..a3edb897e 100644 --- a/frontend/src/components/Settings/SettingsCategoryContent.tsx +++ b/frontend/src/components/Settings/SettingsCategoryContent.tsx @@ -13,6 +13,7 @@ const AuthenticationSettings = lazy(SETTINGS_PAGE_LOADERS["admin-authentication" const ConnectionsSettings = lazy(SETTINGS_PAGE_LOADERS.connections); const FileBrowserSettings = lazy(SETTINGS_PAGE_LOADERS["file-browser"]); const FileSearchSettings = lazy(SETTINGS_PAGE_LOADERS["admin-file-search"]); +const DirectorySearchSettings = lazy(SETTINGS_PAGE_LOADERS["admin-directory-search"]); const LocalDrivesSettings = lazy(SETTINGS_PAGE_LOADERS["local-drives"]); const NetworkSettings = lazy(SETTINGS_PAGE_LOADERS["admin-network"]); const AppearanceSettings = lazy(SETTINGS_PAGE_LOADERS.appearance); @@ -79,6 +80,7 @@ function SettingsCategoryPage({ case "local-drives": return ( } @@ -92,6 +94,8 @@ function SettingsCategoryPage({ return isAdmin ? : null; case "admin-file-search": return isAdmin ? : null; + case "admin-directory-search": + return isAdmin ? : null; case "admin-system": return isAdmin ? : null; case "admin-about": diff --git a/frontend/src/components/Settings/SettingsCategoryList.tsx b/frontend/src/components/Settings/SettingsCategoryList.tsx index 28f0d9d87..2ce1d6458 100644 --- a/frontend/src/components/Settings/SettingsCategoryList.tsx +++ b/frontend/src/components/Settings/SettingsCategoryList.tsx @@ -2,6 +2,7 @@ import { Article as ArticleIcon, ChevronRight as ChevronRightIcon, Dns as DnsIcon, + Folder as FolderIcon, FolderOpen as FolderOpenIcon, InfoOutlined as InfoOutlinedIcon, ManageAccounts as ManageAccountsIcon, @@ -133,6 +134,8 @@ function renderCategoryIcon(item: SettingsNavItem, iconGlyphSx?: SxProps) return ; case "admin-file-search": return ; + case "admin-directory-search": + return ; case "admin-system": return ; case "admin-about": diff --git a/frontend/src/components/Settings/SettingsFieldHelp.tsx b/frontend/src/components/Settings/SettingsFieldHelp.tsx index 6cc1b6afc..9e172bebb 100644 --- a/frontend/src/components/Settings/SettingsFieldHelp.tsx +++ b/frontend/src/components/Settings/SettingsFieldHelp.tsx @@ -4,9 +4,15 @@ import type { ReactNode } from "react"; interface SettingsFieldHelpProps { children: ReactNode; sx?: SxProps; + id?: string; + error?: boolean; } /** Use below settings controls when their explanation is not provided by TextField. */ -export function SettingsFieldHelp({ children, sx }: SettingsFieldHelpProps) { - return {children}; +export function SettingsFieldHelp({ children, sx, id, error }: SettingsFieldHelpProps) { + return ( + + {children} + + ); } diff --git a/frontend/src/components/Settings/__tests__/CompanionPolicyDelivery.test.tsx b/frontend/src/components/Settings/__tests__/CompanionPolicyDelivery.test.tsx new file mode 100644 index 000000000..78431d0ed --- /dev/null +++ b/frontend/src/components/Settings/__tests__/CompanionPolicyDelivery.test.tsx @@ -0,0 +1,286 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import contract from "../../../../../shared/directory-index/contract.json"; +import { clearCachedAsyncData } from "../../../hooks/useCachedAsyncData"; +import { SambeeThemeProvider } from "../../../theme"; +import { DIRECTORY_INDEX_POLICY_FIELDS, type DirectoryIndexPolicyScope, type DirectoryIndexSettings } from "../../../types"; +import { CompanionDirectoryStatus, CompanionPolicyDelivery } from "../CompanionPolicyDelivery"; + +vi.mock("../../../services/api", () => ({ + default: { + getThemes: () => Promise.resolve({ themes: [], site_default_id: "sambee-light" }), + getCurrentUser: () => Promise.resolve({ role: "editor" }), + getAdvancedSettings: vi.fn(), + updateAdvancedSettings: vi.fn(), + getDirectoryIndexSettings: vi.fn(), + updateDirectoryIndexSettings: vi.fn(), + issueDirectoryPolicyGrant: vi.fn(), + }, +})); + +import api from "../../../services/api"; + +vi.mock("../../../services/companion", () => ({ + default: { getDirectoryPolicyStatus: vi.fn(), requestDirectoryPolicyChallenge: vi.fn(), redeemDirectoryPolicyGrant: vi.fn() }, +})); + +import companionService from "../../../services/companion"; + +function directorySettings(scope: DirectoryIndexPolicyScope): DirectoryIndexSettings { + return { + scope, + backend_identity: "verified-backend", + revision: 0, + policy: { + version: 1, + ...Object.fromEntries(DIRECTORY_INDEX_POLICY_FIELDS.map((field) => [field, contract.policy[field].default])), + } as DirectoryIndexSettings["policy"], + fields: Object.fromEntries( + DIRECTORY_INDEX_POLICY_FIELDS.map((field) => [ + field, + { + key: `directory_index.${scope}.${field}`, + label: contract.policy[field].label, + description: contract.policy[field].description, + value: contract.policy[field].default, + source: "default", + default_value: contract.policy[field].default, + min_value: contract.policy[field].minimum, + max_value: contract.policy[field].maximum, + step: contract.policy[field].step, + }, + ]) + ) as DirectoryIndexSettings["fields"], + }; +} + +describe("CompanionPolicyDelivery", () => { + beforeEach(() => { + vi.clearAllMocks(); + clearCachedAsyncData(); + vi.mocked(api.updateAdvancedSettings).mockImplementation(async (update) => update); + vi.mocked(api.getDirectoryIndexSettings).mockImplementation(async (scope) => directorySettings(scope)); + vi.mocked(api.updateDirectoryIndexSettings).mockImplementation(async (_scope, update) => update); + vi.mocked(companionService.getDirectoryPolicyStatus).mockResolvedValue({ + version: 1, + companion_id: "native-id", + status: "unowned", + owner_origin: null, + backend_identity: null, + effective_revision: null, + policy: directorySettings("companion").policy, + challenge: null, + approved: false, + message: null, + }); + }); + + it.each([ + [404, "Companion update required. This Companion has no policy approval support.", true], + [503, "Companion is offline or unavailable. Its last approved policy remains active.", false], + [403, "Companion authorization is unavailable. Check pairing in Local Drives, then refresh its status.", false], + ])("shows actionable companion policy status for HTTP %s and recovers after refresh", async (status, message, blocked) => { + const user = userEvent.setup(); + vi.mocked(companionService.getDirectoryPolicyStatus).mockRejectedValueOnce({ response: { status } }); + render( + + + + ); + expect(await screen.findByText(message)).toBeInTheDocument(); + const apply = screen.getByRole("button", { name: "Apply To This Companion" }); + if (blocked) expect(apply).toBeDisabled(); + else expect(apply).toBeEnabled(); + expect(api.issueDirectoryPolicyGrant).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Refresh Status" })); + expect(await screen.findByText("Desired policy is saved. Companion approval or delivery is pending.")).toBeInTheDocument(); + expect(apply).toBeEnabled(); + }); + + it("shows native status without admin reads or approval controls for non-admins", async () => { + render(); + expect(await screen.findByText(/Contact an administrator to apply it/)).toBeInTheDocument(); + expect(api.getDirectoryIndexSettings).not.toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: "Apply To This Companion" })).not.toBeInTheDocument(); + expect(api.issueDirectoryPolicyGrant).not.toHaveBeenCalled(); + await waitFor(() => expect(companionService.getDirectoryPolicyStatus).toHaveBeenCalledOnce()); + }); + + it("loads desired policy only for admins and does not apply it automatically", async () => { + render(); + await waitFor(() => expect(screen.getByRole("button", { name: "Apply To This Companion" })).toBeEnabled()); + expect(api.getDirectoryIndexSettings).toHaveBeenCalledExactlyOnceWith("companion"); + expect(companionService.getDirectoryPolicyStatus).toHaveBeenCalledOnce(); + expect(companionService.requestDirectoryPolicyChallenge).not.toHaveBeenCalled(); + }); + + it("keeps native status readable and allows retry when the admin policy read fails", async () => { + const user = userEvent.setup(); + vi.mocked(api.getDirectoryIndexSettings).mockRejectedValueOnce({ response: { data: { detail: "Policy read failed. Retry." } } }); + render(); + expect(await screen.findByText("Policy read failed. Retry.")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Apply To This Companion" })).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Retry" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Apply To This Companion" })).toBeEnabled()); + }); + + it("aborts an old approval when the desired policy changes", async () => { + const user = userEvent.setup(); + let approve!: (status: Awaited>) => void; + vi.mocked(companionService.requestDirectoryPolicyChallenge).mockImplementation( + () => + new Promise((resolve) => { + approve = resolve; + }) + ); + const view = render(); + await user.click(await screen.findByRole("button", { name: "Apply To This Companion" })); + const options = vi.mocked(companionService.requestDirectoryPolicyChallenge).mock.calls[0]![0]; + view.rerender(); + expect(options?.signal?.aborted).toBe(true); + await act(async () => { + approve({ + version: 1, + companion_id: "native-id", + status: "pending_delivery", + owner_origin: null, + backend_identity: null, + effective_revision: null, + policy: directorySettings("companion").policy, + challenge: "stale", + approved: true, + message: null, + }); + }); + expect(api.issueDirectoryPolicyGrant).not.toHaveBeenCalled(); + expect(companionService.redeemDirectoryPolicyGrant).not.toHaveBeenCalled(); + }); + + it("delivers only an opaque grant after native approval", async () => { + const user = userEvent.setup(); + const pending = { + version: 1, + companion_id: "native-id", + status: "pending_delivery" as const, + owner_origin: null, + backend_identity: null, + effective_revision: null, + policy: directorySettings("companion").policy, + challenge: "native-challenge", + approved: true, + message: null, + }; + vi.mocked(companionService.requestDirectoryPolicyChallenge).mockResolvedValue(pending); + vi.mocked(api.issueDirectoryPolicyGrant).mockResolvedValue({ + grant: "opaque-grant", + expires_in_seconds: 60, + revision: 0, + backend_identity: "verified-backend", + }); + vi.mocked(companionService.redeemDirectoryPolicyGrant).mockResolvedValue({ + ...pending, + status: "applied", + challenge: null, + owner_origin: window.location.origin, + backend_identity: "verified-backend", + effective_revision: 0, + }); + render( + + + + ); + await user.click(await screen.findByRole("button", { name: "Apply To This Companion" })); + await waitFor(() => + expect(api.issueDirectoryPolicyGrant).toHaveBeenCalledWith( + { companion_id: "native-id", challenge: "native-challenge", origin: window.location.origin }, + expect.objectContaining({ signal: expect.anything() }) + ) + ); + expect(companionService.redeemDirectoryPolicyGrant).toHaveBeenCalledWith( + "opaque-grant", + expect.objectContaining({ signal: expect.anything() }) + ); + expect(await screen.findByText("Companion policy is active.")).toBeInTheDocument(); + }); + + it("does not issue a grant after the settings surface is closed", async () => { + const user = userEvent.setup(); + let approve!: (status: Awaited>) => void; + vi.mocked(companionService.requestDirectoryPolicyChallenge).mockImplementation( + () => + new Promise((resolve) => { + approve = resolve; + }) + ); + const view = render( + + + + ); + await user.click(await screen.findByRole("button", { name: "Apply To This Companion" })); + view.unmount(); + approve({ + version: 1, + companion_id: "native-id", + status: "pending_delivery", + owner_origin: null, + backend_identity: null, + effective_revision: null, + policy: directorySettings("companion").policy, + challenge: "native-challenge", + approved: true, + message: null, + }); + await Promise.resolve(); + expect(api.issueDirectoryPolicyGrant).not.toHaveBeenCalled(); + expect(companionService.redeemDirectoryPolicyGrant).not.toHaveBeenCalled(); + }); + + it("requires native owner reset instead of delivering to a conflicting owner", async () => { + vi.mocked(companionService.getDirectoryPolicyStatus).mockResolvedValue({ + version: 1, + companion_id: "native-id", + status: "owner_conflict", + owner_origin: "https://other.example", + backend_identity: "other-backend", + effective_revision: 0, + policy: directorySettings("companion").policy, + challenge: null, + approved: false, + message: null, + }); + render( + + + + ); + expect(await screen.findByText("Another backend owns companion policy. Reset that owner in native Preferences.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Apply To This Companion" })).toBeDisabled(); + expect(api.issueDirectoryPolicyGrant).not.toHaveBeenCalled(); + }); + + it("does not treat an equal revision from a different backend identity as active", async () => { + vi.mocked(companionService.getDirectoryPolicyStatus).mockResolvedValue({ + version: 1, + companion_id: "native-id", + status: "applied", + owner_origin: window.location.origin, + backend_identity: "previous-backend", + effective_revision: 0, + policy: directorySettings("companion").policy, + challenge: null, + approved: false, + message: null, + }); + render( + + + + ); + expect(await screen.findByText("Another backend owns companion policy. Reset that owner in native Preferences.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Apply To This Companion" })).toBeDisabled(); + expect(screen.queryByText("Companion policy is active.")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/Settings/__tests__/SettingsDialog.test.tsx b/frontend/src/components/Settings/__tests__/SettingsDialog.test.tsx index d2887feb9..100c2214e 100644 --- a/frontend/src/components/Settings/__tests__/SettingsDialog.test.tsx +++ b/frontend/src/components/Settings/__tests__/SettingsDialog.test.tsx @@ -83,6 +83,10 @@ vi.mock("../../../pages/AdvancedSettings", () => ({ AdvancedSettings: () =>
System Settings Content
, })); +vi.mock("../../../pages/DirectorySearchSettings", () => ({ + DirectorySearchSettings: () =>
Directory Search Settings Content
, +})); + vi.mock("../../../pages/AboutSettings", () => ({ AboutSettings: () =>
About Settings Content
, })); @@ -179,6 +183,7 @@ describe("SettingsDialog Component", () => { expect(authenticationOption.querySelector("svg")).toBeInTheDocument(); expect(userManagementOption).toBeInTheDocument(); expect(systemOption).toBeInTheDocument(); + expect(screen.getByRole("option", { name: /^directory search$/i }).querySelector("svg")).toBeInTheDocument(); expect(aboutOption).toBeInTheDocument(); expect(aboutOption.querySelector("svg")).toBeInTheDocument(); expect(screen.getByText("Administration")).toBeInTheDocument(); @@ -450,6 +455,18 @@ describe("SettingsDialog Component", () => { expect(await screen.findByText("System Settings Content")).toBeInTheDocument(); }); + it("opens Directory Search for admins but does not render it for regular users", async () => { + const denied = renderWithTheme(); + expect(screen.queryByText("Directory Search Settings Content")).not.toBeInTheDocument(); + expect(scheduleSettingsPagePrefetch).toHaveBeenLastCalledWith(expect.not.arrayContaining(["admin-directory-search"])); + denied.unmount(); + vi.mocked(api.getCurrentUser).mockResolvedValue({ id: "admin-id", username: "admin", role: "admin" }); + const user = userEvent.setup(); + renderWithTheme(); + await user.click(await screen.findByRole("option", { name: /^directory search$/i })); + expect(await screen.findByText("Directory Search Settings Content")).toBeInTheDocument(); + }); + it("switches to About when an admin clicks it", async () => { vi.mocked(api.getCurrentUser).mockResolvedValue({ id: "admin-id", username: "admin", role: "admin" }); const user = userEvent.setup(); diff --git a/frontend/src/components/Settings/__tests__/settingsNavigation.test.ts b/frontend/src/components/Settings/__tests__/settingsNavigation.test.ts index 75a634cb2..026893550 100644 --- a/frontend/src/components/Settings/__tests__/settingsNavigation.test.ts +++ b/frontend/src/components/Settings/__tests__/settingsNavigation.test.ts @@ -41,6 +41,7 @@ describe("settingsNavigation", () => { "admin-users", "admin-smb", "admin-file-search", + "admin-directory-search", "admin-system", "admin-about", ], @@ -60,6 +61,7 @@ describe("settingsNavigation", () => { expect(getSettingsCategoryByPath("/settings/admin/users")).toBe("admin-users"); expect(getSettingsCategoryByPath("/settings/admin/smb")).toBe("admin-smb"); expect(getSettingsCategoryByPath("/settings/admin/file-search")).toBe("admin-file-search"); + expect(getSettingsCategoryByPath("/settings/admin/directory-search")).toBe("admin-directory-search"); expect(getSettingsCategoryByPath("/settings/admin/system")).toBe("admin-system"); }); @@ -97,6 +99,7 @@ describe("settingsNavigation", () => { expect(getSettingsCategoryLabel("file-browser")).toBe("File Browser"); expect(getSettingsCategoryLabel("text-editor")).toBe("Text Editor"); expect(getSettingsCategoryLabel("admin-file-search")).toBe("File Search"); + expect(getSettingsCategoryLabel("admin-directory-search")).toBe("Directory Search"); expect(getSettingsNavItemLabel("local-drives")).toBe("Local Drives"); expect(getSettingsViewTitle("main")).toBe("Settings"); @@ -110,6 +113,7 @@ describe("settingsNavigation", () => { expect(getSettingsCategoryLabel("text-editor")).toMatch(/^\[.*\]$/); expect(getSettingsCategoryLabel("admin-file-search")).not.toBe("File Search"); expect(getSettingsCategoryLabel("admin-file-search")).toMatch(/^\[.*\]$/); + expect(getSettingsCategoryLabel("admin-directory-search")).toMatch(/^\[.*\]$/); expect(getSettingsNavItemLabel("local-drives")).not.toBe("Local Drives"); expect(getSettingsNavItemLabel("local-drives")).toMatch(/^\[.*\]$/); expect(getSettingsViewTitle("main")).toBe("[Šéťťíńğš]"); diff --git a/frontend/src/components/Settings/settingsCategories.ts b/frontend/src/components/Settings/settingsCategories.ts index 09f6317ea..e380efb32 100644 --- a/frontend/src/components/Settings/settingsCategories.ts +++ b/frontend/src/components/Settings/settingsCategories.ts @@ -10,6 +10,7 @@ export const SETTINGS_CATEGORY_ORDER = [ "admin-users", "admin-smb", "admin-file-search", + "admin-directory-search", "admin-system", "admin-about", ] as const; diff --git a/frontend/src/components/Settings/settingsDataSources.ts b/frontend/src/components/Settings/settingsDataSources.ts index 17f2699ac..a0cf8eb88 100644 --- a/frontend/src/components/Settings/settingsDataSources.ts +++ b/frontend/src/components/Settings/settingsDataSources.ts @@ -31,6 +31,8 @@ export const SETTINGS_DATA_CACHE_KEYS = { adminUsers: "settings-data/admin-users", adminUserContext: "settings-data/admin-user-context", adminSystem: "settings-data/admin-system", + directoryIndexBackend: "settings-data/directory-index/backend", + directoryIndexCompanion: "settings-data/directory-index/companion", adminSmb: "settings-data/admin-smb", adminNetwork: "settings-data/admin-network", adminAuthentication: "settings-data/admin-authentication", diff --git a/frontend/src/components/Settings/settingsNavigation.ts b/frontend/src/components/Settings/settingsNavigation.ts index 9211d923f..1e19f9bd0 100644 --- a/frontend/src/components/Settings/settingsNavigation.ts +++ b/frontend/src/components/Settings/settingsNavigation.ts @@ -36,6 +36,7 @@ export const SETTINGS_ROUTE_BY_CATEGORY: Record = { "admin-users": "/settings/admin/users", "admin-smb": "/settings/admin/smb", "admin-file-search": "/settings/admin/file-search", + "admin-directory-search": "/settings/admin/directory-search", "admin-system": "/settings/admin/system", "admin-about": "/settings/admin/about", }; @@ -145,6 +146,15 @@ export const SETTINGS_CATEGORY_META: Record import("../../pages/UserManagementSettings").then((module) => ({ default: module.UserManagementSettings })), "admin-smb": () => import("../../pages/SmbSettings").then((module) => ({ default: module.SmbSettings })), "admin-file-search": () => import("../../pages/FileSearchSettings").then((module) => ({ default: module.FileSearchSettings })), + "admin-directory-search": () => + import("../../pages/DirectorySearchSettings").then((module) => ({ default: module.DirectorySearchSettings })), "admin-system": () => import("../../pages/AdvancedSettings").then((module) => ({ default: module.AdvancedSettings })), "admin-about": () => import("../../pages/AboutSettings").then((module) => ({ default: module.AboutSettings })), }; diff --git a/frontend/src/i18n/resources.ts b/frontend/src/i18n/resources.ts index 8f2397dd3..e1d2d8b31 100644 --- a/frontend/src/i18n/resources.ts +++ b/frontend/src/i18n/resources.ts @@ -184,11 +184,40 @@ export const EN_TRANSLATIONS = { label: "File Search", description: "Configure File Search history, result limits, and exclusions.", }, + adminDirectorySearch: { + label: "Directory Search", + description: "Configure directory-index resources and verification for the backend and all Companion devices.", + }, adminAbout: { label: "About", description: "Review Sambee build, software, and hardware information.", }, }, + directorySearch: { + loadFailed: "Could not load directory-search policy. Check the backend connection, then retry.", + saveFailed: "Could not save this directory-search setting. Check the value and backend connection, then retry.", + backendTitle: "Backend Directory Indexes", + companionTitle: "Companion Directory Indexes", + deviceTitle: "Directory Search on This Device", + unavailable: "Companion policy is unavailable. Check pairing and the Companion connection, then retry.", + approvalExpired: "Local policy approval was denied or expired. Request a new approval.", + ownerConflict: "Another backend owns companion policy. Reset that owner in native Preferences.", + confirmationMismatch: "Companion policy confirmation does not match the issued grant. Refresh its status before retrying.", + legacy: "Companion update required. This Companion has no policy approval support.", + authorization: "Companion authorization is unavailable. Check pairing in Local Drives, then refresh its status.", + refreshing: "Refreshing companion policy status.", + waitingApproval: "Waiting for local companion approval.", + delivering: "Delivering approved companion policy.", + active: "Companion policy is active.", + offline: "Companion is offline or unavailable. Its last approved policy remains active.", + pending: "Desired policy is saved. Companion approval or delivery is pending.", + notApplied: "Directory-search policy has not been applied to this Companion. Contact an administrator to apply it.", + desiredRevision: "Desired revision: {{revision}}.", + activeRevision: "Active revision: {{revision}}.", + noRevision: "not applied", + apply: "Apply To This Companion", + refresh: "Refresh Status", + }, aboutPage: { applicationTitle: "Application", runtimeTitle: "Software", @@ -744,6 +773,13 @@ export const EN_TRANSLATIONS = { indexing: "Indexing... ({{count}} directories found)", updating: "Updating index... ({{count}} directories)", startingIndex: "Starting index...", + indexCoveragePartial: "Directory index has incomplete coverage.", + indexCoverageUncertain: "Directory index is not verified. A new check is pending.", + legacyDirectorySearch: + "Multi-term directory search is not confirmed for this source. Search one directory term or check backend and companion support.", + historyUnavailable: "Recent directories are unavailable. Check the backend connection and retry.", + fileHistoryUnavailable: "Recent files are unavailable. Check the backend connection and retry.", + indexUnavailable: "Directory search is unavailable. Check the source connection and retry.", }, groups: { recentFiles: "Recent files", diff --git a/frontend/src/pages/AdvancedSettings.tsx b/frontend/src/pages/AdvancedSettings.tsx index cc2cbff50..9bc5bb72f 100644 --- a/frontend/src/pages/AdvancedSettings.tsx +++ b/frontend/src/pages/AdvancedSettings.tsx @@ -1,18 +1,17 @@ -import { Alert, Box, Button, FormControl, FormHelperText, InputAdornment, MenuItem, Stack, TextField } from "@mui/material"; +import { Alert, Button, Stack } from "@mui/material"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { ByteSizeSettingField, SettingField } from "../components/Settings/IntegerSettingFields"; import { SettingsGroup } from "../components/Settings/SettingsGroup"; import { SettingsPage } from "../components/Settings/SettingsPage"; import { SettingsSectionList } from "../components/Settings/SettingsSectionList"; import { SettingsLoadingState } from "../components/Settings/SettingsState"; import { loadAdvancedSettingsData, SETTINGS_DATA_CACHE_KEYS } from "../components/Settings/settingsDataSources"; import { useCachedAsyncData } from "../hooks/useCachedAsyncData"; -import { SettingPersistenceIndicator, useSystemSettingPersistence } from "../hooks/useSystemSettingPersistence"; -import { translate } from "../i18n"; +import { useSystemSettingPersistence } from "../hooks/useSystemSettingPersistence"; import api from "../services/api"; import type { AdvancedSystemSettings, AdvancedSystemSettingsUpdate, IntegerSystemSetting } from "../types"; import { getApiErrorMessage } from "../utils/apiErrors"; -import { formatLocalizedNumber } from "../utils/localeFormatting"; interface AdvancedSettingsProps { dialogSafeHeader?: boolean; @@ -36,41 +35,6 @@ interface AdvancedSettingsFormState { pdfScreenMaxDecodedPixels: number | null; } -const DESKTOP_FIELD_ROW_MAX_WIDTH = 440; -const DESKTOP_VALUE_FIELD_MAX_WIDTH = 220; -const DESKTOP_UNIT_FIELD_WIDTH = 120; -const DESKTOP_NUMERIC_FIELD_MAX_WIDTH = 240; - -const BYTE_UNITS = [ - { label: "B", factor: 1 }, - { label: "KiB", factor: 1024 }, - { label: "MiB", factor: 1024 * 1024 }, - { label: "GiB", factor: 1024 * 1024 * 1024 }, -] as const; - -type ByteUnitLabel = (typeof BYTE_UNITS)[number]["label"]; - -function formatInteger(value: number): string { - return formatLocalizedNumber(value); -} - -function formatByteSize(bytes: number): string { - const matchingUnit = [...BYTE_UNITS].reverse().find((unit) => bytes >= unit.factor && bytes % unit.factor === 0) ?? BYTE_UNITS[0]; - return `${formatInteger(bytes / matchingUnit.factor)} ${matchingUnit.label}`; -} - -function formatBytesWithExactValue(bytes: number): string { - return `${formatByteSize(bytes)} (${formatInteger(bytes)} bytes)`; -} - -function getPreferredByteUnit(bytes: number): ByteUnitLabel { - return ([...BYTE_UNITS].reverse().find((unit) => bytes >= unit.factor && bytes % unit.factor === 0) ?? BYTE_UNITS[0]).label; -} - -function getByteUnitFactor(unitLabel: ByteUnitLabel): number { - return BYTE_UNITS.find((unit) => unit.label === unitLabel)?.factor ?? 1; -} - function createFormState(settings: AdvancedSystemSettings): AdvancedSettingsFormState { return { temporaryArchiveDownloadSizeBytes: settings.temporary_archive_download_size_bytes?.value ?? null, @@ -91,282 +55,6 @@ function createFormState(settings: AdvancedSystemSettings): AdvancedSettingsForm }; } -function validateIntegerSetting(setting: IntegerSystemSetting, value: number | null, unitLabel?: string): string | null { - if (value === null) { - return translate("settings.advanced.validation.enterLabel", { label: setting.label }); - } - - if (!Number.isInteger(value)) { - return translate("settings.advanced.validation.wholeNumber", { label: setting.label }); - } - - if (value < setting.min_value || value > setting.max_value) { - if (unitLabel) { - return translate("settings.advanced.validation.betweenRangeWithUnit", { - label: setting.label, - min: formatInteger(setting.min_value), - max: formatInteger(setting.max_value), - unit: unitLabel, - }); - } - - return translate("settings.advanced.validation.betweenRange", { - label: setting.label, - min: formatInteger(setting.min_value), - max: formatInteger(setting.max_value), - }); - } - - return null; -} - -function validateByteSizeSetting(setting: IntegerSystemSetting, value: number | null): string | null { - if (value === null) { - return translate("settings.advanced.validation.enterLabel", { label: setting.label }); - } - - if (!Number.isInteger(value)) { - return translate("settings.advanced.validation.wholeNumber", { label: setting.label }); - } - - if (value < setting.min_value || value > setting.max_value) { - return translate("settings.advanced.validation.betweenRange", { - label: setting.label, - min: formatByteSize(setting.min_value), - max: formatByteSize(setting.max_value), - }); - } - - return null; -} - -function SettingField({ - setting, - value, - onChange, - onCommit, - persistenceError, - pending, - saved, - unitAdornment, -}: { - setting: IntegerSystemSetting; - value: number | null; - onChange: (value: number | null) => void; - onCommit: (value: number) => void; - persistenceError?: string; - pending: boolean; - saved: boolean; - unitAdornment?: string; -}) { - const { t } = useTranslation(); - const [displayValue, setDisplayValue] = useState(value === null ? "" : String(value)); - const [touched, setTouched] = useState(false); - - useEffect(() => { - setDisplayValue(value === null ? "" : String(value)); - }, [value]); - - const validationError = validateIntegerSetting(setting, value, unitAdornment); - const errorText = persistenceError ?? (touched ? validationError : null); - const helperText = errorText - ? errorText - : t("settings.advanced.helperText.integer", { - description: setting.description, - defaultValue: setting.default_value, - minValue: setting.min_value, - maxValue: setting.max_value, - }); - - return ( - - { - const nextValue = event.target.value; - if (!/^\d*$/.test(nextValue)) { - return; - } - - setDisplayValue(nextValue); - onChange(nextValue ? Number(nextValue) : null); - }} - onBlur={() => { - setTouched(true); - if (!validationError && value !== null) onCommit(value); - }} - variant="outlined" - sx={{ width: "100%", maxWidth: { xs: "100%", sm: DESKTOP_NUMERIC_FIELD_MAX_WIDTH } }} - disabled={pending} - error={Boolean(errorText)} - slotProps={{ - htmlInput: { inputMode: "numeric", pattern: "[0-9]*" }, - input: - unitAdornment || pending || saved - ? { - endAdornment: ( - - {unitAdornment} - - - ), - } - : undefined, - }} - helperText={helperText} - /> - - ); -} - -function ByteSizeSettingField({ - setting, - fixedUnit, - value, - onChange, - onCommit, - persistenceError, - pending, - saved, -}: { - setting: IntegerSystemSetting; - fixedUnit?: ByteUnitLabel; - value: number | null; - onChange: (value: number | null) => void; - onCommit: (value: number) => void; - persistenceError?: string; - pending: boolean; - saved: boolean; -}) { - const { t } = useTranslation(); - const [unit, setUnit] = useState(() => fixedUnit ?? getPreferredByteUnit(value ?? setting.min_value)); - const [displayValue, setDisplayValue] = useState(() => - value === null ? "" : String(value / getByteUnitFactor(fixedUnit ?? getPreferredByteUnit(value))) - ); - const [touched, setTouched] = useState(false); - - useEffect(() => { - if (value === null) { - setDisplayValue(""); - return; - } - - const factor = getByteUnitFactor(unit); - if (!fixedUnit && factor > value && value > 0) { - const nextUnit = getPreferredByteUnit(value); - setUnit(nextUnit); - setDisplayValue(String(value / getByteUnitFactor(nextUnit))); - return; - } - - setDisplayValue(String(value / factor)); - }, [fixedUnit, unit, value]); - - const factor = getByteUnitFactor(unit); - const validationError = - validateByteSizeSetting(setting, value) ?? - (fixedUnit && value !== null && value % setting.step !== 0 ? `${setting.label} must use whole ${fixedUnit} increments` : null); - const errorText = persistenceError ?? (touched ? validationError : null); - - const handleValueChange = (nextValue: string) => { - if (!/^\d*$/.test(nextValue)) { - return; - } - - setDisplayValue(nextValue); - - if (!nextValue) { - onChange(null); - return; - } - - onChange(Number(nextValue) * factor); - }; - - const handleValueBlur = () => { - setTouched(true); - if (!validationError && value !== null) onCommit(value); - }; - - const handleUnitChange = (nextUnit: ByteUnitLabel) => { - setUnit(nextUnit); - }; - - const availableUnits = BYTE_UNITS.filter((option) => - fixedUnit ? option.label === fixedUnit : value === null || value % option.factor === 0 || option.label === unit - ); - const helperMessage = errorText - ? errorText - : t("settings.advanced.helperText.byteSize", { - description: setting.description, - defaultValue: formatBytesWithExactValue(setting.default_value), - minValue: formatByteSize(setting.min_value), - maxValue: formatByteSize(setting.max_value), - }); - - return ( - - - - handleValueChange(event.target.value)} - onBlur={handleValueBlur} - slotProps={{ - htmlInput: { inputMode: "numeric", pattern: "[0-9]*" }, - input: - pending || saved - ? { - endAdornment: ( - - - - ), - } - : undefined, - }} - variant="outlined" - sx={{ width: "100%", maxWidth: { xs: "100%", sm: DESKTOP_VALUE_FIELD_MAX_WIDTH } }} - disabled={pending} - error={Boolean(errorText)} - /> - handleUnitChange(event.target.value as ByteUnitLabel)} - variant="outlined" - disabled={pending || Boolean(fixedUnit)} - error={Boolean(errorText)} - sx={{ width: { xs: "100%", sm: DESKTOP_UNIT_FIELD_WIDTH } }} - > - {availableUnits.map((option) => ( - - {option.label} - - ))} - - - {helperMessage} - - - ); -} - function applyIntegerSettingUpdate(setting: IntegerSystemSetting, update: AdvancedSystemSettingsUpdate): IntegerSystemSetting { return setting.key === update.field ? { ...setting, value: update.value, source: "database" } : setting; } diff --git a/frontend/src/pages/DirectorySearchSettings.tsx b/frontend/src/pages/DirectorySearchSettings.tsx new file mode 100644 index 000000000..baa263f1f --- /dev/null +++ b/frontend/src/pages/DirectorySearchSettings.tsx @@ -0,0 +1,121 @@ +import { Alert, Button } from "@mui/material"; +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ByteSizeSettingField, SettingField } from "../components/Settings/IntegerSettingFields"; +import { SettingsGroup } from "../components/Settings/SettingsGroup"; +import { SettingsPage } from "../components/Settings/SettingsPage"; +import { SettingsSectionList } from "../components/Settings/SettingsSectionList"; +import { SettingsLoadingState } from "../components/Settings/SettingsState"; +import { SETTINGS_DATA_CACHE_KEYS } from "../components/Settings/settingsDataSources"; +import { useCachedAsyncData } from "../hooks/useCachedAsyncData"; +import { useSystemSettingPersistence } from "../hooks/useSystemSettingPersistence"; +import api from "../services/api"; +import { + DIRECTORY_INDEX_POLICY_FIELDS, + type DirectoryIndexPolicyField, + type DirectoryIndexPolicyScope, + type DirectoryIndexSettings, + type DirectoryIndexSettingUpdate, +} from "../types"; +import { getApiErrorMessage } from "../utils/apiErrors"; + +const DIRECTORY_INDEX_LOADERS = { + backend: () => api.getDirectoryIndexSettings("backend"), + companion: () => api.getDirectoryIndexSettings("companion"), +} as const; + +function DirectoryIndexPolicyGroup({ scope }: { scope: DirectoryIndexPolicyScope }) { + const { t } = useTranslation(); + const [loadError, setLoadError] = useState(null); + const [drafts, setDrafts] = useState>>({}); + const handleLoadError = useCallback( + (error: unknown) => setLoadError(getApiErrorMessage(error, t("settings.directorySearch.loadFailed"))), + [t] + ); + const { data, loading, refresh, setData } = useCachedAsyncData({ + cacheKey: scope === "backend" ? SETTINGS_DATA_CACHE_KEYS.directoryIndexBackend : SETTINGS_DATA_CACHE_KEYS.directoryIndexCompanion, + load: DIRECTORY_INDEX_LOADERS[scope], + onError: handleLoadError, + }); + const persistence = useSystemSettingPersistence( + (update, options) => api.updateDirectoryIndexSettings(scope, update, options), + (error) => getApiErrorMessage(error, t("settings.directorySearch.saveFailed")) + ); + const persistField = async (field: DirectoryIndexPolicyField, value: number) => { + const result = await persistence.persist({ field, value }, data?.policy[field]); + if (result.status !== "completed") return; + setData((current) => + current + ? { + ...current, + policy: { ...current.policy, [field]: result.update.value }, + fields: { ...current.fields, [field]: { ...current.fields[field], value: result.update.value, source: "db" } }, + } + : current + ); + setDrafts((current) => ({ ...current, [field]: result.update.value })); + void refresh(); + }; + return ( + + {loadError && ( + { + setLoadError(null); + void refresh(true); + }} + disabled={loading} + > + {t("common.actions.retry")} + + } + > + {loadError} + + )} + {loading && !data && } + {data && + DIRECTORY_INDEX_POLICY_FIELDS.map((field) => { + const setting = data.fields[field]; + const value = Object.hasOwn(drafts, field) ? (drafts[field] ?? null) : data.policy[field]; + const props = { + setting, + value, + onChange: (next: number | null) => { + setDrafts((current) => ({ ...current, [field]: next })); + persistence.clearFieldFeedback(field); + }, + onCommit: (next: number) => void persistField(field, next), + persistenceError: persistence.fieldErrors[field], + pending: persistence.isPending(field), + saved: persistence.isSaved(field), + }; + return field === "disk_budget_bytes" ? ( + + ) : ( + + ); + })} + + ); +} + +export function DirectorySearchSettings({ dialogSafeHeader = false }: { dialogSafeHeader?: boolean }) { + return ( + + + + + + + ); +} diff --git a/frontend/src/pages/FileBrowser.tsx b/frontend/src/pages/FileBrowser.tsx index 4f12e13ff..c64cee606 100644 --- a/frontend/src/pages/FileBrowser.tsx +++ b/frontend/src/pages/FileBrowser.tsx @@ -508,7 +508,11 @@ const Browser: React.FC = () => { t, setTransferNotice ); - useSyncExternalStore(browserContentServices.subscribe, browserContentServices.getSnapshot, browserContentServices.getSnapshot); + const storageSnapshot = useSyncExternalStore( + browserContentServices.subscribe, + browserContentServices.getSnapshot, + browserContentServices.getSnapshot + ); useEffect(() => { browserContentServices.updateConnections(allConnections); @@ -704,6 +708,7 @@ const Browser: React.FC = () => { contentProviders: browserContentServices.providers, storageRegistry: browserContentServices.registry, history: browserContentServices.history, + sourceRevision: storageSnapshot.capabilityRevision, linkTargets: browserContentServices.linkTargets, rowHeight, disabled: settingsOpen || mobileSettingsOpen, @@ -724,6 +729,7 @@ const Browser: React.FC = () => { contentProviders: browserContentServices.providers, storageRegistry: browserContentServices.registry, history: browserContentServices.history, + sourceRevision: storageSnapshot.capabilityRevision, linkTargets: browserContentServices.linkTargets, rowHeight, disabled: settingsOpen || mobileSettingsOpen || paneMode === "single", diff --git a/frontend/src/pages/FileBrowser/types.ts b/frontend/src/pages/FileBrowser/types.ts index a4e85281e..4105f61db 100644 --- a/frontend/src/pages/FileBrowser/types.ts +++ b/frontend/src/pages/FileBrowser/types.ts @@ -153,6 +153,7 @@ export interface UseFileBrowserPaneConfig { /** Recent-history and directory-navigation service composed at the page boundary. */ history?: BrowserHistoryService; + sourceRevision?: number; /** Link-target metadata service composed at the page boundary. */ linkTargets?: BrowserLinkTargetService; diff --git a/frontend/src/pages/FileBrowser/useFileBrowserPane.ts b/frontend/src/pages/FileBrowser/useFileBrowserPane.ts index 673856d9b..4efdcfd86 100644 --- a/frontend/src/pages/FileBrowser/useFileBrowserPane.ts +++ b/frontend/src/pages/FileBrowser/useFileBrowserPane.ts @@ -597,6 +597,7 @@ export function useFileBrowserPane(config: UseFileBrowserPaneConfig): UseFileBro { includeDotDirectories: includeDotDirectoriesInQuickNav, history, + sourceRevision: config.sourceRevision, getConnectionName: (targetConnectionId) => getConnectionById(connections, targetConnectionId)?.name ?? targetConnectionId, onNavigateDirectory: (targetConnectionId, path, recordId) => { pendingRecentDirectoryRecordRef.current = recordId ? { id: recordId, connectionId: targetConnectionId, path } : null; diff --git a/frontend/src/pages/LocalDrivesSettings.tsx b/frontend/src/pages/LocalDrivesSettings.tsx index e5f068137..eb1cc2f18 100644 --- a/frontend/src/pages/LocalDrivesSettings.tsx +++ b/frontend/src/pages/LocalDrivesSettings.tsx @@ -12,6 +12,7 @@ import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react import { CompactBadge as Chip } from "../components/CompactBadge"; import CompanionPairingDialog from "../components/FileBrowser/CompanionPairingDialog"; import { FormRow, FormSurface } from "../components/Form/FormLayout"; +import { CompanionDirectoryStatus } from "../components/Settings/CompanionPolicyDelivery"; import { LOCAL_DRIVES_PAGE_COPY } from "../components/Settings/localDrivesCopy"; import { SettingsInlineAlert, SettingsNotificationSnackbar, type SettingsNotificationState } from "../components/Settings/SettingsFeedback"; import { SettingsGroup } from "../components/Settings/SettingsGroup"; @@ -92,6 +93,7 @@ function isUnsupportedMobileCompanionPlatform(): boolean { } interface LocalDrivesSettingsProps { + isAdmin?: boolean; onConnectionsChanged?: () => void; sectionTitle?: string; sectionDescription?: ReactNode; @@ -128,7 +130,7 @@ const EMPTY_LOCAL_DRIVES_STATE: LocalDrivesSettingsData = { * Browser-side management UI for the Sambee Companion pairing that exposes * local drives inside the file browser. */ -export function LocalDrivesSettings({ onConnectionsChanged, sectionTitle, sectionDescription }: LocalDrivesSettingsProps) { +export function LocalDrivesSettings({ isAdmin = false, onConnectionsChanged, sectionTitle, sectionDescription }: LocalDrivesSettingsProps) { const companionUnsupportedOnCurrentDevice = useMemo(() => isUnsupportedMobileCompanionPlatform(), []); const companionSigningAvailable = canSignCompanionRequests(); const [testing, setTesting] = useState(false); @@ -524,6 +526,8 @@ export function LocalDrivesSettings({ onConnectionsChanged, sectionTitle, sectio + {viewState === "paired" && } + {shouldShowInstallSection && ( diff --git a/frontend/src/pages/__tests__/AdvancedSettings.test.tsx b/frontend/src/pages/__tests__/AdvancedSettings.test.tsx index af9cc256a..940ef805a 100644 --- a/frontend/src/pages/__tests__/AdvancedSettings.test.tsx +++ b/frontend/src/pages/__tests__/AdvancedSettings.test.tsx @@ -1,9 +1,15 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import contract from "../../../../shared/directory-index/contract.json"; import { clearCachedAsyncData } from "../../hooks/useCachedAsyncData"; import { SambeeThemeProvider } from "../../theme"; -import type { AdvancedSystemSettings } from "../../types"; +import { + type AdvancedSystemSettings, + DIRECTORY_INDEX_POLICY_FIELDS, + type DirectoryIndexPolicyScope, + type DirectoryIndexSettings, +} from "../../types"; import { AdvancedSettings } from "../AdvancedSettings"; vi.mock("../../services/api", () => ({ @@ -12,11 +18,20 @@ vi.mock("../../services/api", () => ({ getCurrentUser: () => Promise.resolve({ role: "editor" }), getAdvancedSettings: vi.fn(), updateAdvancedSettings: vi.fn(), + getDirectoryIndexSettings: vi.fn(), + updateDirectoryIndexSettings: vi.fn(), + issueDirectoryPolicyGrant: vi.fn(), }, })); import api from "../../services/api"; +vi.mock("../../services/companion", () => ({ + default: { getDirectoryPolicyStatus: vi.fn(), requestDirectoryPolicyChallenge: vi.fn(), redeemDirectoryPolicyGrant: vi.fn() }, +})); + +import companionService from "../../services/companion"; + const mockAdvancedSettings: AdvancedSystemSettings = { preprocessors: { imagemagick: { @@ -46,12 +61,54 @@ const mockAdvancedSettings: AdvancedSystemSettings = { }, }; +function directorySettings(scope: DirectoryIndexPolicyScope): DirectoryIndexSettings { + return { + scope, + backend_identity: "verified-backend", + revision: 0, + policy: { + version: 1, + ...Object.fromEntries(DIRECTORY_INDEX_POLICY_FIELDS.map((field) => [field, contract.policy[field].default])), + } as DirectoryIndexSettings["policy"], + fields: Object.fromEntries( + DIRECTORY_INDEX_POLICY_FIELDS.map((field) => [ + field, + { + key: `directory_index.${scope}.${field}`, + label: contract.policy[field].label, + description: contract.policy[field].description, + value: contract.policy[field].default, + source: "default", + default_value: contract.policy[field].default, + min_value: contract.policy[field].minimum, + max_value: contract.policy[field].maximum, + step: contract.policy[field].step, + }, + ]) + ) as DirectoryIndexSettings["fields"], + }; +} + describe("AdvancedSettings", () => { beforeEach(() => { vi.clearAllMocks(); clearCachedAsyncData(); vi.mocked(api.getAdvancedSettings).mockResolvedValue(mockAdvancedSettings); vi.mocked(api.updateAdvancedSettings).mockImplementation(async (update) => update); + vi.mocked(api.getDirectoryIndexSettings).mockImplementation(async (scope) => directorySettings(scope)); + vi.mocked(api.updateDirectoryIndexSettings).mockImplementation(async (_scope, update) => update); + vi.mocked(companionService.getDirectoryPolicyStatus).mockResolvedValue({ + version: 1, + companion_id: "native-id", + status: "unowned", + owner_origin: null, + backend_identity: null, + effective_revision: null, + policy: directorySettings("companion").policy, + challenge: null, + approved: false, + message: null, + }); }); it("loads and displays advanced settings", async () => { @@ -71,6 +128,11 @@ describe("AdvancedSettings", () => { expect(screen.getByLabelText("Conversion timeout")).toBeInTheDocument(); expect(screen.queryByLabelText("Value")).not.toBeInTheDocument(); expect(screen.queryByText(/exact value:/i)).not.toBeInTheDocument(); + expect(api.getDirectoryIndexSettings).not.toHaveBeenCalled(); + expect(companionService.getDirectoryPolicyStatus).not.toHaveBeenCalled(); + expect(screen.queryByText("Companion Directory Indexes")).not.toBeInTheDocument(); + expect(screen.getByLabelText("Conversion timeout")).toHaveAccessibleDescription(/Maximum time allowed/); + expect(screen.getByLabelText("Maximum file size")).toHaveAccessibleDescription(/Largest input file/); }); it("persists a valid updated value on blur", async () => { @@ -145,4 +207,19 @@ describe("AdvancedSettings", () => { expect(maximumFileSize).toHaveValue("102400"); expect(api.updateAdvancedSettings).not.toHaveBeenCalled(); }); + + it("commits a byte-size value on Enter without changing its unit", async () => { + const user = userEvent.setup(); + render(); + const maximumFileSize = await screen.findByLabelText("Maximum file size"); + await user.clear(maximumFileSize); + await user.type(maximumFileSize, "128"); + await user.keyboard("{Enter}"); + await waitFor(() => + expect(api.updateAdvancedSettings).toHaveBeenCalledWith( + { field: "preprocessors.imagemagick.max_file_size_bytes", value: 134217728 }, + expect.objectContaining({ signal: expect.anything() }) + ) + ); + }); }); diff --git a/frontend/src/pages/__tests__/DirectorySearchSettings.test.tsx b/frontend/src/pages/__tests__/DirectorySearchSettings.test.tsx new file mode 100644 index 000000000..1090445d5 --- /dev/null +++ b/frontend/src/pages/__tests__/DirectorySearchSettings.test.tsx @@ -0,0 +1,151 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import contract from "../../../../shared/directory-index/contract.json"; +import { clearCachedAsyncData } from "../../hooks/useCachedAsyncData"; +import { SambeeThemeProvider } from "../../theme"; +import { DIRECTORY_INDEX_POLICY_FIELDS, type DirectoryIndexPolicyScope, type DirectoryIndexSettings } from "../../types"; +import { DirectorySearchSettings } from "../DirectorySearchSettings"; + +vi.mock("../../services/api", () => ({ + default: { + getThemes: () => Promise.resolve({ themes: [], site_default_id: "sambee-light" }), + getCurrentUser: () => Promise.resolve({ role: "editor" }), + getAdvancedSettings: vi.fn(), + updateAdvancedSettings: vi.fn(), + getDirectoryIndexSettings: vi.fn(), + updateDirectoryIndexSettings: vi.fn(), + issueDirectoryPolicyGrant: vi.fn(), + }, +})); + +import api from "../../services/api"; + +vi.mock("../../services/companion", () => ({ + default: { getDirectoryPolicyStatus: vi.fn(), requestDirectoryPolicyChallenge: vi.fn(), redeemDirectoryPolicyGrant: vi.fn() }, +})); + +import companionService from "../../services/companion"; + +function directorySettings(scope: DirectoryIndexPolicyScope): DirectoryIndexSettings { + return { + scope, + backend_identity: "verified-backend", + revision: 0, + policy: { + version: 1, + ...Object.fromEntries(DIRECTORY_INDEX_POLICY_FIELDS.map((field) => [field, contract.policy[field].default])), + } as DirectoryIndexSettings["policy"], + fields: Object.fromEntries( + DIRECTORY_INDEX_POLICY_FIELDS.map((field) => [ + field, + { + key: `directory_index.${scope}.${field}`, + label: contract.policy[field].label, + description: contract.policy[field].description, + value: contract.policy[field].default, + source: "default", + default_value: contract.policy[field].default, + min_value: contract.policy[field].minimum, + max_value: contract.policy[field].maximum, + step: contract.policy[field].step, + }, + ]) + ) as DirectoryIndexSettings["fields"], + }; +} + +describe("DirectorySearchSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + clearCachedAsyncData(); + vi.mocked(api.updateAdvancedSettings).mockImplementation(async (update) => update); + vi.mocked(api.getDirectoryIndexSettings).mockImplementation(async (scope) => directorySettings(scope)); + vi.mocked(api.updateDirectoryIndexSettings).mockImplementation(async (_scope, update) => update); + vi.mocked(companionService.getDirectoryPolicyStatus).mockResolvedValue({ + version: 1, + companion_id: "native-id", + status: "unowned", + owner_origin: null, + backend_identity: null, + effective_revision: null, + policy: directorySettings("companion").policy, + challenge: null, + approved: false, + message: null, + }); + }); + + it("does not reload directory policies on idle renders or uncommitted edits", async () => { + const user = userEvent.setup(); + const view = render( + + + + ); + const fields = await screen.findAllByLabelText("SQLite page-cache target"); + expect(api.getDirectoryIndexSettings).toHaveBeenCalledTimes(2); + expect(api.getDirectoryIndexSettings).toHaveBeenCalledWith("backend"); + expect(api.getDirectoryIndexSettings).toHaveBeenCalledWith("companion"); + + view.rerender( + + + + ); + await user.clear(fields[0]!); + await user.type(fields[0]!, "1024"); + + expect(api.getDirectoryIndexSettings).toHaveBeenCalledTimes(2); + expect(api.updateDirectoryIndexSettings).not.toHaveBeenCalled(); + }); + + it("shows separate directory scopes and persists only the selected host", async () => { + const user = userEvent.setup(); + render( + + + + ); + const fields = await screen.findAllByLabelText("SQLite page-cache target"); + expect(fields).toHaveLength(2); + expect(companionService.getDirectoryPolicyStatus).not.toHaveBeenCalled(); + await user.clear(fields[0]!); + await user.type(fields[0]!, "1024"); + await user.keyboard("{Enter}"); + await waitFor(() => + expect(api.updateDirectoryIndexSettings).toHaveBeenCalledWith( + "backend", + { field: "page_cache_kib", value: 1024 }, + expect.objectContaining({ signal: expect.anything() }) + ) + ); + expect(fields[1]).toHaveValue("2048"); + expect(api.updateAdvancedSettings).not.toHaveBeenCalled(); + }); + + it("keeps invalid drafts local and associates a failed save with the Companion field", async () => { + const user = userEvent.setup(); + vi.mocked(api.updateDirectoryIndexSettings).mockRejectedValue({ + response: { data: { detail: "Policy save failed. Retry this field." } }, + }); + render(); + const fields = await screen.findAllByLabelText("SQLite page-cache target"); + const companionField = fields[1]!; + await user.clear(companionField); + await user.type(companionField, "1"); + await user.keyboard("{Enter}"); + expect(api.updateDirectoryIndexSettings).not.toHaveBeenCalled(); + await user.clear(companionField); + await user.type(companionField, "1024"); + await user.keyboard("{Enter}"); + await waitFor(() => expect(companionField).toHaveAccessibleDescription("Policy save failed. Retry this field.")); + expect(api.updateDirectoryIndexSettings).toHaveBeenCalledWith( + "companion", + { field: "page_cache_kib", value: 1024 }, + expect.objectContaining({ signal: expect.anything() }) + ); + expect(fields[0]).toHaveValue("2048"); + expect(fields[0]).not.toHaveAttribute("aria-invalid", "true"); + }); +}); diff --git a/frontend/src/pages/__tests__/FileBrowser-image-viewer.test.tsx b/frontend/src/pages/__tests__/FileBrowser-image-viewer.test.tsx index 554fd9947..1a90d2cde 100644 --- a/frontend/src/pages/__tests__/FileBrowser-image-viewer.test.tsx +++ b/frontend/src/pages/__tests__/FileBrowser-image-viewer.test.tsx @@ -213,8 +213,12 @@ describe("Browser - Image View Integration", () => { })), }); mockedApi.getFileInfo.mockResolvedValue({ type: FileType.FILE, mime_type: "image/jpeg", size: 10 } as never); + let rejectSelectedImage: ((reason: unknown) => void) | undefined; + const selectedImageRequest = new Promise((_resolve, reject) => { + rejectSelectedImage = reject; + }); loadImageBlob.mockImplementation(async (path: string) => { - if (path === "two.jpg") throw { response: { status: 404, data: { detail: { code: "item_not_found" } } } }; + if (path === "two.jpg") return selectedImageRequest; return new Blob(["image"], { type: "image/jpeg" }); }); await renderBrowser(); @@ -222,6 +226,10 @@ describe("Browser - Image View Integration", () => { await screen.findByTestId("image-view"); await waitFor(() => expect(loadImageBlob).toHaveBeenCalledTimes(2)); fireEvent.click(screen.getByRole("button", { name: "Next image" })); + await waitFor(() => expect(screen.getByTestId("image-view")).toHaveAttribute("data-path", "two.jpg")); + await act(async () => { + requireTestValue(rejectSelectedImage)({ response: { status: 404, data: { detail: { code: "item_not_found" } } } }); + }); await waitFor(() => { expect(screen.getByRole("alert")).toBeInTheDocument(); expect(screen.getByTestId("image-view")).toHaveAttribute("data-path", "two.jpg"); diff --git a/frontend/src/pages/__tests__/LocalDrivesSettings.test.tsx b/frontend/src/pages/__tests__/LocalDrivesSettings.test.tsx index b4485d849..89a005369 100644 --- a/frontend/src/pages/__tests__/LocalDrivesSettings.test.tsx +++ b/frontend/src/pages/__tests__/LocalDrivesSettings.test.tsx @@ -47,6 +47,7 @@ vi.mock("../../services/companion", () => ({ unpairCurrentOrigin: mockUnpairCurrentOrigin, cancelPairing: vi.fn(), initiatePairing: vi.fn(), + getDirectoryPolicyStatus: vi.fn().mockResolvedValue({ status: "unowned", effective_revision: null }), }, clearStoredSecret: vi.fn(), hasStoredSecret: mockHasStoredSecret, @@ -198,7 +199,8 @@ describe("LocalDrivesSettings", () => { await user.click(await screen.findByRole("button", { name: LOCAL_DRIVES_PAGE_COPY.testCurrentPairingButton })); - const successStatus = await screen.findByRole("status"); + const successStatus = await screen.findByText(LOCAL_DRIVES_PAGE_COPY.pairingTestSucceeded); + expect(successStatus.closest('[role="status"]')).toBeInTheDocument(); expect(successStatus).toHaveTextContent(LOCAL_DRIVES_PAGE_COPY.pairingTestSucceeded); expect(screen.queryByText("Pairing with http://localhost:3000 is working.")).not.toBeInTheDocument(); }); diff --git a/frontend/src/services/__tests__/browseApi.test.ts b/frontend/src/services/__tests__/browseApi.test.ts index 56643ad61..863a65543 100644 --- a/frontend/src/services/__tests__/browseApi.test.ts +++ b/frontend/src/services/__tests__/browseApi.test.ts @@ -6,6 +6,7 @@ import type { AxiosResponse } from "axios"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SEARCH_CONTRACT_VERSION } from "../../components/FileBrowser/search/recentHistory"; import { type DirectoryListing, type DirectorySearchResult, type FileInfo, FileType } from "../../types"; // Mock axios before importing services @@ -608,12 +609,13 @@ describe("Browse API Contract Tests", () => { // ========================================================================== describe("Contract Tests - GET /browse/{connection_id}/directories", () => { - it("should return directory search result format", async () => { + it.each([undefined, null, SEARCH_CONTRACT_VERSION])("preserves directory search contract version %s", async (searchContractVersion) => { const backendResponse: DirectorySearchResult = { results: ["documents", "documents/work", "documents/personal"], total_matches: 3, cache_state: "ready", directory_count: 150, + search_contract_version: searchContractVersion, }; mockAxiosInstance.get.mockResolvedValueOnce({ @@ -627,6 +629,7 @@ describe("Browse API Contract Tests", () => { expect(result).toHaveProperty("total_matches"); expect(result).toHaveProperty("cache_state"); expect(result).toHaveProperty("directory_count"); + expect(result.search_contract_version).toBe(searchContractVersion); // Verify types expect(Array.isArray(result.results)).toBe(true); diff --git a/frontend/src/services/__tests__/companionApi.test.ts b/frontend/src/services/__tests__/companionApi.test.ts index 1167102d2..365e86ea9 100644 --- a/frontend/src/services/__tests__/companionApi.test.ts +++ b/frontend/src/services/__tests__/companionApi.test.ts @@ -1,5 +1,5 @@ import type { AxiosResponse } from "axios"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, assert, beforeEach, describe, expect, it, vi } from "vitest"; // Mock axios before importing the API service vi.mock("axios", () => { @@ -9,6 +9,7 @@ vi.mock("axios", () => { put: vi.fn(), delete: vi.fn(), defaults: { baseURL: "/api" }, + getUri: vi.fn(() => "http://localhost:21549/api/browse/private-drive/directories"), interceptors: { request: { use: vi.fn(), @@ -30,11 +31,15 @@ vi.mock("axios", () => { import axios from "axios"; import apiService from "../api"; +import { companionSession } from "../companionSession"; +import { logger } from "../logger"; const mockedAxios = vi.mocked(axios); const mockAxiosInstance = mockedAxios.create() as ReturnType & { post: ReturnType; }; +const companionRequestInterceptor = vi.mocked(mockAxiosInstance.interceptors.request.use).mock.calls.at(-1)?.[0]; +const companionResponseInterceptors = vi.mocked(mockAxiosInstance.interceptors.response.use).mock.calls.at(-1); describe("Companion API", () => { beforeEach(() => { @@ -52,6 +57,86 @@ describe("Companion API", () => { vi.clearAllMocks(); }); + it.each([ + { + status: 200, + code: undefined, + cancelled: false, + url: "/browse/private-drive/directories", + route: "/browse/{drive}/directories", + revision: 7, + }, + { + status: 503, + code: "ERR_BAD_RESPONSE", + cancelled: false, + url: "/browse/private-drive/directories", + route: "/browse/{drive}/directories", + revision: undefined, + }, + { + status: null, + code: "ERR_CANCELED", + cancelled: true, + url: "/browse/private-drive/directories", + route: "/browse/{drive}/directories", + revision: undefined, + }, + { + status: 200, + code: undefined, + cancelled: false, + url: "/browse/private-drive/private-filename?token=private-token", + route: "/{route}", + revision: "private-revision", + }, + ])( + "logs private-safe companion timing for status $status cancellation $cancelled", + async ({ status, code, cancelled, url, route, revision }) => { + const { AxiosError, AxiosHeaders } = await vi.importActual("axios"); + assert(companionRequestInterceptor); + assert(companionResponseInterceptors?.[0]); + assert(companionResponseInterceptors?.[1]); + const debug = vi.spyOn(logger, "debug").mockImplementation(() => undefined); + vi.spyOn(companionSession, "getSigningHeaders").mockResolvedValue({ "X-Companion-Secret": "private-signature" }); + vi.spyOn(performance, "now").mockReturnValueOnce(100).mockReturnValue(125); + const config = await companionRequestInterceptor({ + method: "get", + url, + headers: new AxiosHeaders({ Authorization: "private-authorization" }), + params: { q: "private-terms", path: "private-path", hmac: "private-signed-value" }, + data: { grant: "private-grant" }, + }); + const response = { + config, + status: status ?? 0, + statusText: "private-status-text", + headers: new AxiosHeaders(), + data: { accepted_revision: revision }, + }; + if (code) { + const error = new AxiosError("private-error-message", code, config, undefined, status === null ? undefined : response); + await expect(companionResponseInterceptors[1](error)).rejects.toBe(error); + } else { + expect(companionResponseInterceptors[0](response)).toBe(response); + } + expect(debug).toHaveBeenCalledWith( + "Companion API request finished", + { + method: "GET", + route, + status, + duration_ms: 25, + cancelled, + ...(typeof revision === "number" ? { accepted_revision: revision } : {}), + }, + "companion" + ); + expect(debug).toHaveBeenCalledTimes(1); + expect(JSON.stringify(debug.mock.calls)).not.toContain("private-"); + } + ); + describe("getCompanionUri()", () => { it("builds a sambee:// URI with server, token, connection, and path", async () => { mockAxiosInstance.defaults.baseURL = "/api"; diff --git a/frontend/src/services/__tests__/companionService.test.ts b/frontend/src/services/__tests__/companionService.test.ts index 5780c0bac..0603f1539 100644 --- a/frontend/src/services/__tests__/companionService.test.ts +++ b/frontend/src/services/__tests__/companionService.test.ts @@ -9,6 +9,7 @@ import { } from "../companion"; import { companionSession } from "../companionSession"; import { getLocalDrivePairingErrorKind } from "../localDrivePairing"; +import { logger } from "../logger"; describe("companion authentication protocol", () => { it("distinguishes older, matching, and newer Companion versions", () => { @@ -98,6 +99,23 @@ vi.mock("axios", () => ({ }, })); +describe("companionService.initiatePairing", () => { + it("does not log the pairing identity, code, or nonces", async () => { + const pairingId = "private-pairing-identity"; + const nonceCompanion = "private-companion-nonce"; + mockAxiosInstance.post.mockResolvedValueOnce({ data: { pairing_id: pairingId, nonce_companion: nonceCompanion } }); + const info = vi.spyOn(logger, "info").mockImplementation(() => undefined); + const result = await companionService.initiatePairing(); + expect(result.pairingId).toBe(pairingId); + expect(result.pairingCode).toHaveLength(6); + expect(info).toHaveBeenCalledWith("Pairing initiated", {}, "companion"); + const logged = JSON.stringify(info.mock.calls); + for (const privateValue of [pairingId, result.pairingCode, result.nonceBrowser, nonceCompanion]) { + expect(logged).not.toContain(privateValue); + } + }); +}); + describe("companionService.confirmPairing", () => { beforeEach(() => { localStorage.clear(); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index dd513e4bc..d707beefc 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -29,7 +29,12 @@ import type { CurrentAccount, CurrentUserSettings, CurrentUserSettingsUpdate, + DirectoryIndexPolicyScope, + DirectoryIndexSettings, + DirectoryIndexSettingUpdate, DirectoryListing, + DirectoryPolicyGrant, + DirectoryPolicyGrantBinding, DirectorySearchResult, EditLockInfo, EditLockStatus, @@ -81,6 +86,9 @@ import { type ContentTransferResult, ExpiredEditLockError, type TargetResolution export { ExpiredEditLockError } from "./storageContracts"; +const COMPANION_TIMING_BROWSE_OPERATIONS = new Set(["directories", "list", "info"]); +const COMPANION_TIMING_OTHER_ROUTE = "/{route}"; + export interface DirectorySearchOptions { includeDotDirectories?: boolean; signal?: AbortSignal; @@ -372,6 +380,7 @@ class ApiService { private api: AxiosInstance; /** Separate axios instance for companion requests (no Bearer interceptor). */ private companionApi: AxiosInstance; + private companionRequestStarts = new WeakMap(); constructor() { // Use absolute URL for tests (required by MSW), relative for production @@ -553,12 +562,53 @@ class ApiService { }); this.companionApi.interceptors.request.use(async (config) => { Object.assign(config.headers, await companionSession.getSigningHeaders(config.method ?? "GET", this.companionApi.getUri(config))); + this.companionRequestStarts.set(config, performance.now()); return config; }); + this.companionApi.interceptors.response.use( + (response) => { + this.logCompanionTiming(response.config, response.status, false, response.data?.accepted_revision); + return response; + }, + (error: AxiosError) => { + this.logCompanionTiming(error.config, error.response?.status ?? null, isLocalAbortError(error)); + return Promise.reject(error); + } + ); } // ── Routing helpers ───────────────────────────────────────────────────── + private logCompanionTiming(config: AxiosRequestConfig | undefined, status: number | null, cancelled: boolean, revision?: unknown): void { + if (!config) return; + const started = this.companionRequestStarts.get(config); + if (started === undefined) return; + this.companionRequestStarts.delete(config); + let route = COMPANION_TIMING_OTHER_ROUTE; + try { + const segments = new URL(config.url ?? "", `${COMPANION_BASE_URL}/`).pathname.split("/").filter(Boolean); + if (segments[0] === "api") segments.shift(); + const operation = segments[2]; + if (segments.length === 3 && segments[0] === "browse" && operation && COMPANION_TIMING_BROWSE_OPERATIONS.has(operation)) { + route = `/browse/{drive}/${operation}`; + } + } catch { + route = COMPANION_TIMING_OTHER_ROUTE; + } + logger.debug( + "Companion API request finished", + { + method: config.method?.toUpperCase() ?? "GET", + route, + status, + duration_ms: Math.max(0, performance.now() - started), + cancelled, + ...(typeof revision === "number" && Number.isSafeInteger(revision) && revision >= 0 ? { accepted_revision: revision } : {}), + }, + "companion" + ); + } + /** * Build HMAC auth headers for companion requests. */ @@ -908,6 +958,25 @@ class ApiService { return response.data; } + async getDirectoryIndexSettings(scope: DirectoryIndexPolicyScope): Promise { + const response = await this.api.get(`/admin/settings/directory-index/${scope}`); + return response.data; + } + + async issueDirectoryPolicyGrant(binding: DirectoryPolicyGrantBinding, options?: { signal?: AbortSignal }): Promise { + const response = await this.api.post("/admin/settings/directory-index/companion/grants", binding, options); + return response.data; + } + + async updateDirectoryIndexSettings( + scope: DirectoryIndexPolicyScope, + payload: DirectoryIndexSettingUpdate, + options?: { signal?: AbortSignal } + ): Promise { + const response = await this.api.put(`/admin/settings/directory-index/${scope}`, payload, options); + return response.data; + } + async getAboutSettings(): Promise { const response = await this.api.get("/admin/settings/about"); return response.data; diff --git a/frontend/src/services/companion.ts b/frontend/src/services/companion.ts index 78d299b8b..08acec1a6 100644 --- a/frontend/src/services/companion.ts +++ b/frontend/src/services/companion.ts @@ -13,7 +13,7 @@ */ import axios, { type AxiosInstance } from "axios"; -import { isApiError } from "../types"; +import { type CompanionDirectoryPolicyStatus, isApiError } from "../types"; import { companionSession } from "./companionSession"; import { logger } from "./logger"; @@ -261,7 +261,7 @@ class CompanionService { * the companion's nonce, and computes the 6-character pairing code. * * @returns Object with `pairingId`, `pairingCode`, and the internal - * `nonceBrowser` (needed for logging/debugging only). + * `nonceBrowser` used to derive the pairing code. */ async initiatePairing(): Promise<{ pairingId: string; @@ -283,7 +283,7 @@ class CompanionService { const hash = await sha256Hex(combined); const pairingCode = hash.slice(0, 6).toUpperCase(); - logger.info("Pairing initiated", { pairingId: pairing_id, pairingCode }, "companion"); + logger.info("Pairing initiated", {}, "companion"); return { pairingId: pairing_id, pairingCode, nonceBrowser }; } @@ -345,6 +345,31 @@ class CompanionService { return response.data; } + async getDirectoryPolicyStatus(options?: { signal?: AbortSignal }): Promise { + const headers = await this.buildAuthHeaders("GET", "/directory-index/policy"); + const response = await this.client.get("/directory-index/policy", { headers, ...options }); + return response.data; + } + + async requestDirectoryPolicyChallenge(options?: { signal?: AbortSignal }): Promise { + const headers = await this.buildAuthHeaders("POST", "/directory-index/policy/challenge"); + const response = await this.client.post("/directory-index/policy/challenge", undefined, { + headers, + ...options, + }); + return response.data; + } + + async redeemDirectoryPolicyGrant(grant: string, options?: { signal?: AbortSignal }): Promise { + const headers = await this.buildAuthHeaders("POST", "/directory-index/policy/redeem"); + const response = await this.client.post( + "/directory-index/policy/redeem", + { grant }, + { headers, ...options } + ); + return response.data; + } + /** Synchronize the current browser localization to the companion. */ async syncLocalization(payload: CompanionLocalizationPayload): Promise { const headers = await this.buildAuthHeaders("POST", "/localization"); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index c94670c5e..5a46f5595 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -263,6 +263,49 @@ export interface FileSearchSettings { excluded_categories: FileSearchExclusionCategory[]; excluded_extensions: string[]; } +export type DirectoryIndexPolicyScope = "backend" | "companion"; +export const DIRECTORY_INDEX_POLICY_FIELDS = [ + "disk_budget_bytes", + "inactive_retention_seconds", + "max_retained_inactive_sources", + "page_cache_kib", + "healthy_verification_seconds", + "degraded_verification_seconds", +] as const; +export type DirectoryIndexPolicyField = (typeof DIRECTORY_INDEX_POLICY_FIELDS)[number]; +export type DirectoryIndexPolicy = Record & { version: 1 }; +export interface DirectoryIndexSettings { + scope: DirectoryIndexPolicyScope; + policy: DirectoryIndexPolicy; + backend_identity: string; + revision: number; + fields: Record; +} +export type DirectoryIndexSettingUpdate = { field: DirectoryIndexPolicyField; value: number }; + +export interface DirectoryPolicyGrantBinding { + companion_id: string; + challenge: string; + origin: string; +} +export interface DirectoryPolicyGrant { + grant: string; + expires_in_seconds: number; + revision: number; + backend_identity: string; +} +export interface CompanionDirectoryPolicyStatus { + version: number; + companion_id: string; + status: "unowned" | "applied" | "pending_local_approval" | "pending_delivery" | "redeeming" | "denied" | "failed" | "owner_conflict"; + owner_origin: string | null; + backend_identity: string | null; + effective_revision: number | null; + policy: DirectoryIndexPolicy; + challenge: string | null; + approved: boolean; + message: string | null; +} export interface FileSearchSettingsRead { settings: FileSearchSettings; @@ -283,11 +326,13 @@ export interface RecentFile { last_opened_at: string; usage_score?: number; match_rank?: number; + matched_term_count?: number; } export interface RecentFileSearchResponse { results: RecentFile[]; result_limit?: number; + search_contract_version?: number; } export interface RecentDirectory { @@ -297,11 +342,13 @@ export interface RecentDirectory { last_visited_at: string; usage_score?: number; match_rank?: number; + matched_term_count?: number; } export interface RecentDirectorySearchResponse { results: RecentDirectory[]; result_limit: number; + search_contract_version?: number; } export interface RecentFileValidationError { @@ -555,11 +602,20 @@ export interface LocalLinkTargetListing { } export interface DirectorySearchResult { + search_contract_version?: number | null; results: string[]; match_ranks?: Record; total_matches: number; cache_state: "empty" | "building" | "ready" | "updating"; directory_count: number; + matched_term_counts?: Record; + accepted_revision?: number | null; + coverage?: "partial" | "complete" | "uncertain" | null; + coverage_issue_count?: number; + last_verified_at_ms?: number | null; + status_message?: string | null; + monitoring_state?: "starting" | "healthy" | "recovering" | "unavailable" | null; + retry_after_ms?: number | null; } export interface EditLockInfo { diff --git a/scripts/generate-search-unicode-contract.py b/scripts/generate-search-unicode-contract.py new file mode 100755 index 000000000..3a2a4d41a --- /dev/null +++ b/scripts/generate-search-unicode-contract.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Generate the versioned search normalization data used outside Python.""" + +from __future__ import annotations + +import argparse +import json +import unicodedata +from pathlib import Path + +OUTPUT_PATH = ( + Path(__file__).resolve().parents[1] / "shared" / "directory-index" / "unicode.json" +) +MAX_UNICODE_CODEPOINT = 0x10FFFF +SURROGATE_START = 0xD800 +SURROGATE_END = 0xDFFF +CONTRACT_VERSION = 1 + + +def generate() -> str: + mapping: list[list[str]] = [] + whitespace: list[str] = [] + word_ranges: list[list[int]] = [] + range_start: int | None = None + for codepoint in range(MAX_UNICODE_CODEPOINT + 1): + character = chr(codepoint) + if not SURROGATE_START <= codepoint <= SURROGATE_END: + decomposed = unicodedata.normalize("NFKD", character) + normalized = "".join( + part for part in decomposed if not unicodedata.combining(part) + ).casefold() + if normalized != character: + mapping.append([character, normalized]) + if character.isspace(): + whitespace.append(character) + if character.isalnum(): + if range_start is None: + range_start = codepoint + elif range_start is not None: + word_ranges.append([range_start, codepoint - 1]) + range_start = None + if range_start is not None: + word_ranges.append([range_start, MAX_UNICODE_CODEPOINT]) + return ( + json.dumps( + { + "version": CONTRACT_VERSION, + "unicode_version": unicodedata.unidata_version, + "normalization": mapping, + "whitespace": whitespace, + "word_ranges": word_ranges, + }, + ensure_ascii=True, + separators=(",", ":"), + ) + + "\n" + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true") + arguments = parser.parse_args() + content = generate() + if arguments.check: + if ( + not OUTPUT_PATH.is_file() + or OUTPUT_PATH.read_text(encoding="utf-8") != content + ): + print( + "Search Unicode contract is missing or stale. Run scripts/generate-search-unicode-contract.py with the backend interpreter." + ) + return 1 + print("Search Unicode contract is current.") + return 0 + OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) + OUTPUT_PATH.write_text(content, encoding="utf-8") + print( + f"Generated search Unicode contract {CONTRACT_VERSION} with Unicode {unicodedata.unidata_version}." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/refresh-backend-lockfiles b/scripts/refresh-backend-lockfiles index f7b21bebd..daad05a37 100755 --- a/scripts/refresh-backend-lockfiles +++ b/scripts/refresh-backend-lockfiles @@ -57,12 +57,10 @@ create_temp_env() { trap 'rm -rf "${TEMP_DIR}"' EXIT "$python_bin" -m venv "${TEMP_DIR}/venv" - # shellcheck disable=SC1091 - source "${TEMP_DIR}/venv/bin/activate" # Use the interpreter's bundled pip so lockfile refresh matches the # repo's normal Python setup more closely than an independently pinned pip. - python -m pip install --quiet "pip-tools==${PIP_TOOLS_VERSION}" + "${TEMP_DIR}/venv/bin/python" -m pip install --quiet "pip-tools==${PIP_TOOLS_VERSION}" } compile_lockfiles() { @@ -72,13 +70,13 @@ compile_lockfiles() { ( cd "${BACKEND_DIR}" - pip-compile \ + "${TEMP_DIR}/venv/bin/python" -m piptools compile \ --allow-unsafe \ --generate-hashes \ --output-file="${output_dir}/${REQUIREMENTS_LOCKFILE_NAME}" \ requirements.txt - pip-compile \ + "${TEMP_DIR}/venv/bin/python" -m piptools compile \ --allow-unsafe \ --generate-hashes \ --output-file="${output_dir}/${REQUIREMENTS_DEV_LOCKFILE_NAME}" \ diff --git a/shared/directory-index/contract.json b/shared/directory-index/contract.json new file mode 100644 index 000000000..83150b0fc --- /dev/null +++ b/shared/directory-index/contract.json @@ -0,0 +1,80 @@ +{ + "version": 1, + "limits": { + "schema_version": 1, + "normalization_version": 1, + "traversal_version": 1, + "max_query_bytes": 4096, + "max_query_terms": 32, + "max_result_limit": 200, + "row_batch_size": 256, + "query_workers": 2, + "scan_workers": 1, + "busy_timeout_ms": 250, + "progress_steps": 1000, + "query_timeout_seconds": 10, + "status_publication_ms": 250, + "event_coalescing_ms": 300, + "idle_release_seconds": 600, + "minimum_full_pass_spacing_seconds": 30, + "max_pending_observations": 1024, + "max_observation_bytes": 1048576, + "max_source_depth": 256, + "max_source_path_bytes": 32768, + "minimum_sqlite_version_number": 3037000, + "write_headroom_bytes": 33554432, + "maintenance_page_batch": 256, + "checkpoint_wal_bytes": 1048576, + "lease_cleanup_batch": 64 + }, + "policy": { + "disk_budget_bytes": { + "label": "Directory-index disk budget", + "description": "Total managed directory-index storage, including retained data, WAL and operation headroom.", + "default": 2147483648, + "minimum": 67108864, + "maximum": 68719476736, + "step": 1048576 + }, + "inactive_retention_seconds": { + "label": "Inactive-source retention", + "description": "Maximum retention after the last index access. Inactive sources can be removed earlier for disk pressure.", + "default": 604800, + "minimum": 60, + "maximum": 7776000, + "step": 60 + }, + "max_retained_inactive_sources": { + "label": "Retained inactive sources", + "description": "Maximum retained inactive source databases. This does not limit active sources or directory coverage.", + "default": 4, + "minimum": 1, + "maximum": 64, + "step": 1 + }, + "page_cache_kib": { + "label": "SQLite page-cache target", + "description": "Page-cache target per SQLite connection in KiB. This is not a process memory limit.", + "default": 2048, + "minimum": 64, + "maximum": 16384, + "step": 64 + }, + "healthy_verification_seconds": { + "label": "Healthy verification interval", + "description": "Full verification interval for active sources with healthy observation.", + "default": 900, + "minimum": 60, + "maximum": 86400, + "step": 30 + }, + "degraded_verification_seconds": { + "label": "Degraded verification interval", + "description": "Full verification interval when observation is unavailable or uncertain. It must not exceed the healthy interval.", + "default": 300, + "minimum": 30, + "maximum": 3600, + "step": 30 + } + } +} diff --git a/shared/directory-index/fts.sql b/shared/directory-index/fts.sql new file mode 100644 index 000000000..61a317e62 --- /dev/null +++ b/shared/directory-index/fts.sql @@ -0,0 +1,4 @@ +CREATE VIRTUAL TABLE IF NOT EXISTS directory_fts USING fts5( + path_key, content='directories', content_rowid='id', + tokenize='trigram case_sensitive 1', detail=full +); diff --git a/shared/directory-index/schema.sql b/shared/directory-index/schema.sql new file mode 100644 index 000000000..96341ed37 --- /dev/null +++ b/shared/directory-index/schema.sql @@ -0,0 +1,73 @@ +PRAGMA auto_vacuum = INCREMENTAL; +PRAGMA application_id = 1396853073; +PRAGMA user_version = 1; +PRAGMA foreign_keys = ON; + +CREATE TABLE generations ( + id INTEGER PRIMARY KEY CHECK (id > 0), + state TEXT NOT NULL CHECK (state IN ('building', 'accepted', 'candidate', 'retired')), + coverage TEXT NOT NULL CHECK (coverage IN ('partial', 'complete', 'uncertain')), + indexed_count INTEGER NOT NULL DEFAULT 0 CHECK (indexed_count >= 0), + issue_count INTEGER NOT NULL DEFAULT 0 CHECK (issue_count >= 0), + scan_baseline INTEGER NOT NULL DEFAULT 0 CHECK (scan_baseline >= 0) +) STRICT; + +CREATE TABLE source_metadata ( + id INTEGER PRIMARY KEY CHECK (id = 1), + source_kind TEXT NOT NULL CHECK (source_kind IN ('smb', 'local')), + source_identity TEXT NOT NULL, + normalization_version INTEGER NOT NULL CHECK (normalization_version = 1), + traversal_version INTEGER NOT NULL CHECK (traversal_version = 1), + selected_generation INTEGER REFERENCES generations(id), + accepted_revision INTEGER NOT NULL DEFAULT 0 CHECK (accepted_revision >= 0), + committed_at_ms INTEGER NOT NULL CHECK (committed_at_ms >= 0), + last_verified_at_ms INTEGER CHECK (last_verified_at_ms >= 0), + last_access_at_ms INTEGER NOT NULL CHECK (last_access_at_ms >= 0) +) STRICT; + +CREATE TABLE directories ( + id INTEGER PRIMARY KEY, + generation_id INTEGER NOT NULL REFERENCES generations(id) ON DELETE CASCADE, + path TEXT NOT NULL CHECK (length(path) > 0 AND substr(path, 1, 1) <> '/' + AND substr(path, -1) <> '/' AND instr(path, char(0)) = 0 + AND instr('/' || path || '/', '//') = 0 + AND instr('/' || path || '/', '/./') = 0 + AND instr('/' || path || '/', '/../') = 0), + path_key TEXT NOT NULL, + basename_key TEXT NOT NULL, + depth INTEGER NOT NULL CHECK (depth >= 0), + has_dot_directory INTEGER NOT NULL CHECK (has_dot_directory IN (0, 1)), + UNIQUE (generation_id, path) +) STRICT; +CREATE INDEX directories_generation ON directories(generation_id); + +CREATE TABLE scan_queue ( + generation_id INTEGER NOT NULL REFERENCES generations(id) ON DELETE CASCADE, + path TEXT NOT NULL, + processed INTEGER NOT NULL DEFAULT 0 CHECK (processed IN (0, 1)), + PRIMARY KEY (generation_id, path) +) STRICT; +CREATE INDEX scan_queue_pending ON scan_queue(generation_id, processed); + +CREATE TABLE coverage_issues ( + generation_id INTEGER NOT NULL REFERENCES generations(id) ON DELETE CASCADE, + code TEXT NOT NULL CHECK (code IN ('access_denied', 'unsupported_encoding', + 'unsafe_traversal', 'identity_unverified', 'event_loss', 'scan_failed')), + count INTEGER NOT NULL CHECK (count > 0), + PRIMARY KEY (generation_id, code) +) STRICT; + +CREATE TRIGGER directories_insert AFTER INSERT ON directories BEGIN + INSERT INTO directory_fts(rowid, path_key) VALUES (new.id, new.path_key); + UPDATE generations SET indexed_count = indexed_count + 1 WHERE id = new.generation_id; +END; +CREATE TRIGGER directories_delete AFTER DELETE ON directories BEGIN + INSERT INTO directory_fts(directory_fts, rowid, path_key) VALUES ('delete', old.id, old.path_key); + UPDATE generations SET indexed_count = indexed_count - 1 WHERE id = old.generation_id; +END; +CREATE TRIGGER directories_update AFTER UPDATE ON directories BEGIN + INSERT INTO directory_fts(directory_fts, rowid, path_key) VALUES ('delete', old.id, old.path_key); + INSERT INTO directory_fts(rowid, path_key) VALUES (new.id, new.path_key); + UPDATE generations SET indexed_count = indexed_count - 1 WHERE id = old.generation_id; + UPDATE generations SET indexed_count = indexed_count + 1 WHERE id = new.generation_id; +END; diff --git a/shared/directory-index/search-fixtures.json b/shared/directory-index/search-fixtures.json new file mode 100644 index 000000000..812334b56 --- /dev/null +++ b/shared/directory-index/search-fixtures.json @@ -0,0 +1,109 @@ +{ + "version": 1, + "normalization": [ + { + "input": "Straße", + "expected": "strasse" + }, + { + "input": "CAFÉ", + "expected": "cafe" + }, + { + "input": "Café", + "expected": "cafe" + }, + { + "input": "ffi", + "expected": "ffi" + }, + { + "input": "Σςσ", + "expected": "σσσ" + }, + { + "input": "a᪰", + "expected": "a" + } + ], + "queries": [ + { + "query": "alpha beta", + "terms": [ + "alpha", + "beta" + ] + }, + { + "query": "ALPHA alpha", + "terms": [ + "alpha" + ] + }, + { + "query": " \t\n", + "terms": [] + }, + { + "query": "́", + "terms": [] + }, + { + "query": "a% _* \"x\"", + "terms": [ + "a%", + "_*", + "\"x\"" + ] + } + ], + "paths": [ + "alpha/beta", + "beta/alpha", + "alpha", + "beta", + "neither", + ".hidden/alpha" + ], + "searches": [ + { + "query": "alpha beta", + "include_dot": false, + "count": 4, + "results": [ + "alpha/beta", + "beta/alpha", + "alpha", + "beta" + ] + }, + { + "query": "beta alpha", + "include_dot": false, + "count": 4, + "results": [ + "alpha/beta", + "beta/alpha", + "alpha", + "beta" + ] + }, + { + "query": "al beta", + "include_dot": false, + "count": 4, + "results": [ + "alpha/beta", + "beta/alpha", + "beta", + "alpha" + ] + }, + { + "query": "", + "include_dot": true, + "count": 0, + "results": [] + } + ] +} diff --git a/shared/directory-index/unicode.json b/shared/directory-index/unicode.json new file mode 100644 index 000000000..41726f634 --- /dev/null +++ b/shared/directory-index/unicode.json @@ -0,0 +1 @@ +{"version":1,"unicode_version":"16.0.0","normalization":[["A","a"],["B","b"],["C","c"],["D","d"],["E","e"],["F","f"],["G","g"],["H","h"],["I","i"],["J","j"],["K","k"],["L","l"],["M","m"],["N","n"],["O","o"],["P","p"],["Q","q"],["R","r"],["S","s"],["T","t"],["U","u"],["V","v"],["W","w"],["X","x"],["Y","y"],["Z","z"],["\u00a0"," "],["\u00a8"," "],["\u00aa","a"],["\u00af"," "],["\u00b2","2"],["\u00b3","3"],["\u00b4"," "],["\u00b5","\u03bc"],["\u00b8"," "],["\u00b9","1"],["\u00ba","o"],["\u00bc","1\u20444"],["\u00bd","1\u20442"],["\u00be","3\u20444"],["\u00c0","a"],["\u00c1","a"],["\u00c2","a"],["\u00c3","a"],["\u00c4","a"],["\u00c5","a"],["\u00c6","\u00e6"],["\u00c7","c"],["\u00c8","e"],["\u00c9","e"],["\u00ca","e"],["\u00cb","e"],["\u00cc","i"],["\u00cd","i"],["\u00ce","i"],["\u00cf","i"],["\u00d0","\u00f0"],["\u00d1","n"],["\u00d2","o"],["\u00d3","o"],["\u00d4","o"],["\u00d5","o"],["\u00d6","o"],["\u00d8","\u00f8"],["\u00d9","u"],["\u00da","u"],["\u00db","u"],["\u00dc","u"],["\u00dd","y"],["\u00de","\u00fe"],["\u00df","ss"],["\u00e0","a"],["\u00e1","a"],["\u00e2","a"],["\u00e3","a"],["\u00e4","a"],["\u00e5","a"],["\u00e7","c"],["\u00e8","e"],["\u00e9","e"],["\u00ea","e"],["\u00eb","e"],["\u00ec","i"],["\u00ed","i"],["\u00ee","i"],["\u00ef","i"],["\u00f1","n"],["\u00f2","o"],["\u00f3","o"],["\u00f4","o"],["\u00f5","o"],["\u00f6","o"],["\u00f9","u"],["\u00fa","u"],["\u00fb","u"],["\u00fc","u"],["\u00fd","y"],["\u00ff","y"],["\u0100","a"],["\u0101","a"],["\u0102","a"],["\u0103","a"],["\u0104","a"],["\u0105","a"],["\u0106","c"],["\u0107","c"],["\u0108","c"],["\u0109","c"],["\u010a","c"],["\u010b","c"],["\u010c","c"],["\u010d","c"],["\u010e","d"],["\u010f","d"],["\u0110","\u0111"],["\u0112","e"],["\u0113","e"],["\u0114","e"],["\u0115","e"],["\u0116","e"],["\u0117","e"],["\u0118","e"],["\u0119","e"],["\u011a","e"],["\u011b","e"],["\u011c","g"],["\u011d","g"],["\u011e","g"],["\u011f","g"],["\u0120","g"],["\u0121","g"],["\u0122","g"],["\u0123","g"],["\u0124","h"],["\u0125","h"],["\u0126","\u0127"],["\u0128","i"],["\u0129","i"],["\u012a","i"],["\u012b","i"],["\u012c","i"],["\u012d","i"],["\u012e","i"],["\u012f","i"],["\u0130","i"],["\u0132","ij"],["\u0133","ij"],["\u0134","j"],["\u0135","j"],["\u0136","k"],["\u0137","k"],["\u0139","l"],["\u013a","l"],["\u013b","l"],["\u013c","l"],["\u013d","l"],["\u013e","l"],["\u013f","l\u00b7"],["\u0140","l\u00b7"],["\u0141","\u0142"],["\u0143","n"],["\u0144","n"],["\u0145","n"],["\u0146","n"],["\u0147","n"],["\u0148","n"],["\u0149","\u02bcn"],["\u014a","\u014b"],["\u014c","o"],["\u014d","o"],["\u014e","o"],["\u014f","o"],["\u0150","o"],["\u0151","o"],["\u0152","\u0153"],["\u0154","r"],["\u0155","r"],["\u0156","r"],["\u0157","r"],["\u0158","r"],["\u0159","r"],["\u015a","s"],["\u015b","s"],["\u015c","s"],["\u015d","s"],["\u015e","s"],["\u015f","s"],["\u0160","s"],["\u0161","s"],["\u0162","t"],["\u0163","t"],["\u0164","t"],["\u0165","t"],["\u0166","\u0167"],["\u0168","u"],["\u0169","u"],["\u016a","u"],["\u016b","u"],["\u016c","u"],["\u016d","u"],["\u016e","u"],["\u016f","u"],["\u0170","u"],["\u0171","u"],["\u0172","u"],["\u0173","u"],["\u0174","w"],["\u0175","w"],["\u0176","y"],["\u0177","y"],["\u0178","y"],["\u0179","z"],["\u017a","z"],["\u017b","z"],["\u017c","z"],["\u017d","z"],["\u017e","z"],["\u017f","s"],["\u0181","\u0253"],["\u0182","\u0183"],["\u0184","\u0185"],["\u0186","\u0254"],["\u0187","\u0188"],["\u0189","\u0256"],["\u018a","\u0257"],["\u018b","\u018c"],["\u018e","\u01dd"],["\u018f","\u0259"],["\u0190","\u025b"],["\u0191","\u0192"],["\u0193","\u0260"],["\u0194","\u0263"],["\u0196","\u0269"],["\u0197","\u0268"],["\u0198","\u0199"],["\u019c","\u026f"],["\u019d","\u0272"],["\u019f","\u0275"],["\u01a0","o"],["\u01a1","o"],["\u01a2","\u01a3"],["\u01a4","\u01a5"],["\u01a6","\u0280"],["\u01a7","\u01a8"],["\u01a9","\u0283"],["\u01ac","\u01ad"],["\u01ae","\u0288"],["\u01af","u"],["\u01b0","u"],["\u01b1","\u028a"],["\u01b2","\u028b"],["\u01b3","\u01b4"],["\u01b5","\u01b6"],["\u01b7","\u0292"],["\u01b8","\u01b9"],["\u01bc","\u01bd"],["\u01c4","dz"],["\u01c5","dz"],["\u01c6","dz"],["\u01c7","lj"],["\u01c8","lj"],["\u01c9","lj"],["\u01ca","nj"],["\u01cb","nj"],["\u01cc","nj"],["\u01cd","a"],["\u01ce","a"],["\u01cf","i"],["\u01d0","i"],["\u01d1","o"],["\u01d2","o"],["\u01d3","u"],["\u01d4","u"],["\u01d5","u"],["\u01d6","u"],["\u01d7","u"],["\u01d8","u"],["\u01d9","u"],["\u01da","u"],["\u01db","u"],["\u01dc","u"],["\u01de","a"],["\u01df","a"],["\u01e0","a"],["\u01e1","a"],["\u01e2","\u00e6"],["\u01e3","\u00e6"],["\u01e4","\u01e5"],["\u01e6","g"],["\u01e7","g"],["\u01e8","k"],["\u01e9","k"],["\u01ea","o"],["\u01eb","o"],["\u01ec","o"],["\u01ed","o"],["\u01ee","\u0292"],["\u01ef","\u0292"],["\u01f0","j"],["\u01f1","dz"],["\u01f2","dz"],["\u01f3","dz"],["\u01f4","g"],["\u01f5","g"],["\u01f6","\u0195"],["\u01f7","\u01bf"],["\u01f8","n"],["\u01f9","n"],["\u01fa","a"],["\u01fb","a"],["\u01fc","\u00e6"],["\u01fd","\u00e6"],["\u01fe","\u00f8"],["\u01ff","\u00f8"],["\u0200","a"],["\u0201","a"],["\u0202","a"],["\u0203","a"],["\u0204","e"],["\u0205","e"],["\u0206","e"],["\u0207","e"],["\u0208","i"],["\u0209","i"],["\u020a","i"],["\u020b","i"],["\u020c","o"],["\u020d","o"],["\u020e","o"],["\u020f","o"],["\u0210","r"],["\u0211","r"],["\u0212","r"],["\u0213","r"],["\u0214","u"],["\u0215","u"],["\u0216","u"],["\u0217","u"],["\u0218","s"],["\u0219","s"],["\u021a","t"],["\u021b","t"],["\u021c","\u021d"],["\u021e","h"],["\u021f","h"],["\u0220","\u019e"],["\u0222","\u0223"],["\u0224","\u0225"],["\u0226","a"],["\u0227","a"],["\u0228","e"],["\u0229","e"],["\u022a","o"],["\u022b","o"],["\u022c","o"],["\u022d","o"],["\u022e","o"],["\u022f","o"],["\u0230","o"],["\u0231","o"],["\u0232","y"],["\u0233","y"],["\u023a","\u2c65"],["\u023b","\u023c"],["\u023d","\u019a"],["\u023e","\u2c66"],["\u0241","\u0242"],["\u0243","\u0180"],["\u0244","\u0289"],["\u0245","\u028c"],["\u0246","\u0247"],["\u0248","\u0249"],["\u024a","\u024b"],["\u024c","\u024d"],["\u024e","\u024f"],["\u02b0","h"],["\u02b1","\u0266"],["\u02b2","j"],["\u02b3","r"],["\u02b4","\u0279"],["\u02b5","\u027b"],["\u02b6","\u0281"],["\u02b7","w"],["\u02b8","y"],["\u02d8"," "],["\u02d9"," "],["\u02da"," "],["\u02db"," "],["\u02dc"," "],["\u02dd"," "],["\u02e0","\u0263"],["\u02e1","l"],["\u02e2","s"],["\u02e3","x"],["\u02e4","\u0295"],["\u0300",""],["\u0301",""],["\u0302",""],["\u0303",""],["\u0304",""],["\u0305",""],["\u0306",""],["\u0307",""],["\u0308",""],["\u0309",""],["\u030a",""],["\u030b",""],["\u030c",""],["\u030d",""],["\u030e",""],["\u030f",""],["\u0310",""],["\u0311",""],["\u0312",""],["\u0313",""],["\u0314",""],["\u0315",""],["\u0316",""],["\u0317",""],["\u0318",""],["\u0319",""],["\u031a",""],["\u031b",""],["\u031c",""],["\u031d",""],["\u031e",""],["\u031f",""],["\u0320",""],["\u0321",""],["\u0322",""],["\u0323",""],["\u0324",""],["\u0325",""],["\u0326",""],["\u0327",""],["\u0328",""],["\u0329",""],["\u032a",""],["\u032b",""],["\u032c",""],["\u032d",""],["\u032e",""],["\u032f",""],["\u0330",""],["\u0331",""],["\u0332",""],["\u0333",""],["\u0334",""],["\u0335",""],["\u0336",""],["\u0337",""],["\u0338",""],["\u0339",""],["\u033a",""],["\u033b",""],["\u033c",""],["\u033d",""],["\u033e",""],["\u033f",""],["\u0340",""],["\u0341",""],["\u0342",""],["\u0343",""],["\u0344",""],["\u0345",""],["\u0346",""],["\u0347",""],["\u0348",""],["\u0349",""],["\u034a",""],["\u034b",""],["\u034c",""],["\u034d",""],["\u034e",""],["\u0350",""],["\u0351",""],["\u0352",""],["\u0353",""],["\u0354",""],["\u0355",""],["\u0356",""],["\u0357",""],["\u0358",""],["\u0359",""],["\u035a",""],["\u035b",""],["\u035c",""],["\u035d",""],["\u035e",""],["\u035f",""],["\u0360",""],["\u0361",""],["\u0362",""],["\u0363",""],["\u0364",""],["\u0365",""],["\u0366",""],["\u0367",""],["\u0368",""],["\u0369",""],["\u036a",""],["\u036b",""],["\u036c",""],["\u036d",""],["\u036e",""],["\u036f",""],["\u0370","\u0371"],["\u0372","\u0373"],["\u0374","\u02b9"],["\u0376","\u0377"],["\u037a"," "],["\u037e",";"],["\u037f","\u03f3"],["\u0384"," "],["\u0385"," "],["\u0386","\u03b1"],["\u0387","\u00b7"],["\u0388","\u03b5"],["\u0389","\u03b7"],["\u038a","\u03b9"],["\u038c","\u03bf"],["\u038e","\u03c5"],["\u038f","\u03c9"],["\u0390","\u03b9"],["\u0391","\u03b1"],["\u0392","\u03b2"],["\u0393","\u03b3"],["\u0394","\u03b4"],["\u0395","\u03b5"],["\u0396","\u03b6"],["\u0397","\u03b7"],["\u0398","\u03b8"],["\u0399","\u03b9"],["\u039a","\u03ba"],["\u039b","\u03bb"],["\u039c","\u03bc"],["\u039d","\u03bd"],["\u039e","\u03be"],["\u039f","\u03bf"],["\u03a0","\u03c0"],["\u03a1","\u03c1"],["\u03a3","\u03c3"],["\u03a4","\u03c4"],["\u03a5","\u03c5"],["\u03a6","\u03c6"],["\u03a7","\u03c7"],["\u03a8","\u03c8"],["\u03a9","\u03c9"],["\u03aa","\u03b9"],["\u03ab","\u03c5"],["\u03ac","\u03b1"],["\u03ad","\u03b5"],["\u03ae","\u03b7"],["\u03af","\u03b9"],["\u03b0","\u03c5"],["\u03c2","\u03c3"],["\u03ca","\u03b9"],["\u03cb","\u03c5"],["\u03cc","\u03bf"],["\u03cd","\u03c5"],["\u03ce","\u03c9"],["\u03cf","\u03d7"],["\u03d0","\u03b2"],["\u03d1","\u03b8"],["\u03d2","\u03c5"],["\u03d3","\u03c5"],["\u03d4","\u03c5"],["\u03d5","\u03c6"],["\u03d6","\u03c0"],["\u03d8","\u03d9"],["\u03da","\u03db"],["\u03dc","\u03dd"],["\u03de","\u03df"],["\u03e0","\u03e1"],["\u03e2","\u03e3"],["\u03e4","\u03e5"],["\u03e6","\u03e7"],["\u03e8","\u03e9"],["\u03ea","\u03eb"],["\u03ec","\u03ed"],["\u03ee","\u03ef"],["\u03f0","\u03ba"],["\u03f1","\u03c1"],["\u03f2","\u03c3"],["\u03f4","\u03b8"],["\u03f5","\u03b5"],["\u03f7","\u03f8"],["\u03f9","\u03c3"],["\u03fa","\u03fb"],["\u03fd","\u037b"],["\u03fe","\u037c"],["\u03ff","\u037d"],["\u0400","\u0435"],["\u0401","\u0435"],["\u0402","\u0452"],["\u0403","\u0433"],["\u0404","\u0454"],["\u0405","\u0455"],["\u0406","\u0456"],["\u0407","\u0456"],["\u0408","\u0458"],["\u0409","\u0459"],["\u040a","\u045a"],["\u040b","\u045b"],["\u040c","\u043a"],["\u040d","\u0438"],["\u040e","\u0443"],["\u040f","\u045f"],["\u0410","\u0430"],["\u0411","\u0431"],["\u0412","\u0432"],["\u0413","\u0433"],["\u0414","\u0434"],["\u0415","\u0435"],["\u0416","\u0436"],["\u0417","\u0437"],["\u0418","\u0438"],["\u0419","\u0438"],["\u041a","\u043a"],["\u041b","\u043b"],["\u041c","\u043c"],["\u041d","\u043d"],["\u041e","\u043e"],["\u041f","\u043f"],["\u0420","\u0440"],["\u0421","\u0441"],["\u0422","\u0442"],["\u0423","\u0443"],["\u0424","\u0444"],["\u0425","\u0445"],["\u0426","\u0446"],["\u0427","\u0447"],["\u0428","\u0448"],["\u0429","\u0449"],["\u042a","\u044a"],["\u042b","\u044b"],["\u042c","\u044c"],["\u042d","\u044d"],["\u042e","\u044e"],["\u042f","\u044f"],["\u0439","\u0438"],["\u0450","\u0435"],["\u0451","\u0435"],["\u0453","\u0433"],["\u0457","\u0456"],["\u045c","\u043a"],["\u045d","\u0438"],["\u045e","\u0443"],["\u0460","\u0461"],["\u0462","\u0463"],["\u0464","\u0465"],["\u0466","\u0467"],["\u0468","\u0469"],["\u046a","\u046b"],["\u046c","\u046d"],["\u046e","\u046f"],["\u0470","\u0471"],["\u0472","\u0473"],["\u0474","\u0475"],["\u0476","\u0475"],["\u0477","\u0475"],["\u0478","\u0479"],["\u047a","\u047b"],["\u047c","\u047d"],["\u047e","\u047f"],["\u0480","\u0481"],["\u0483",""],["\u0484",""],["\u0485",""],["\u0486",""],["\u0487",""],["\u048a","\u048b"],["\u048c","\u048d"],["\u048e","\u048f"],["\u0490","\u0491"],["\u0492","\u0493"],["\u0494","\u0495"],["\u0496","\u0497"],["\u0498","\u0499"],["\u049a","\u049b"],["\u049c","\u049d"],["\u049e","\u049f"],["\u04a0","\u04a1"],["\u04a2","\u04a3"],["\u04a4","\u04a5"],["\u04a6","\u04a7"],["\u04a8","\u04a9"],["\u04aa","\u04ab"],["\u04ac","\u04ad"],["\u04ae","\u04af"],["\u04b0","\u04b1"],["\u04b2","\u04b3"],["\u04b4","\u04b5"],["\u04b6","\u04b7"],["\u04b8","\u04b9"],["\u04ba","\u04bb"],["\u04bc","\u04bd"],["\u04be","\u04bf"],["\u04c0","\u04cf"],["\u04c1","\u0436"],["\u04c2","\u0436"],["\u04c3","\u04c4"],["\u04c5","\u04c6"],["\u04c7","\u04c8"],["\u04c9","\u04ca"],["\u04cb","\u04cc"],["\u04cd","\u04ce"],["\u04d0","\u0430"],["\u04d1","\u0430"],["\u04d2","\u0430"],["\u04d3","\u0430"],["\u04d4","\u04d5"],["\u04d6","\u0435"],["\u04d7","\u0435"],["\u04d8","\u04d9"],["\u04da","\u04d9"],["\u04db","\u04d9"],["\u04dc","\u0436"],["\u04dd","\u0436"],["\u04de","\u0437"],["\u04df","\u0437"],["\u04e0","\u04e1"],["\u04e2","\u0438"],["\u04e3","\u0438"],["\u04e4","\u0438"],["\u04e5","\u0438"],["\u04e6","\u043e"],["\u04e7","\u043e"],["\u04e8","\u04e9"],["\u04ea","\u04e9"],["\u04eb","\u04e9"],["\u04ec","\u044d"],["\u04ed","\u044d"],["\u04ee","\u0443"],["\u04ef","\u0443"],["\u04f0","\u0443"],["\u04f1","\u0443"],["\u04f2","\u0443"],["\u04f3","\u0443"],["\u04f4","\u0447"],["\u04f5","\u0447"],["\u04f6","\u04f7"],["\u04f8","\u044b"],["\u04f9","\u044b"],["\u04fa","\u04fb"],["\u04fc","\u04fd"],["\u04fe","\u04ff"],["\u0500","\u0501"],["\u0502","\u0503"],["\u0504","\u0505"],["\u0506","\u0507"],["\u0508","\u0509"],["\u050a","\u050b"],["\u050c","\u050d"],["\u050e","\u050f"],["\u0510","\u0511"],["\u0512","\u0513"],["\u0514","\u0515"],["\u0516","\u0517"],["\u0518","\u0519"],["\u051a","\u051b"],["\u051c","\u051d"],["\u051e","\u051f"],["\u0520","\u0521"],["\u0522","\u0523"],["\u0524","\u0525"],["\u0526","\u0527"],["\u0528","\u0529"],["\u052a","\u052b"],["\u052c","\u052d"],["\u052e","\u052f"],["\u0531","\u0561"],["\u0532","\u0562"],["\u0533","\u0563"],["\u0534","\u0564"],["\u0535","\u0565"],["\u0536","\u0566"],["\u0537","\u0567"],["\u0538","\u0568"],["\u0539","\u0569"],["\u053a","\u056a"],["\u053b","\u056b"],["\u053c","\u056c"],["\u053d","\u056d"],["\u053e","\u056e"],["\u053f","\u056f"],["\u0540","\u0570"],["\u0541","\u0571"],["\u0542","\u0572"],["\u0543","\u0573"],["\u0544","\u0574"],["\u0545","\u0575"],["\u0546","\u0576"],["\u0547","\u0577"],["\u0548","\u0578"],["\u0549","\u0579"],["\u054a","\u057a"],["\u054b","\u057b"],["\u054c","\u057c"],["\u054d","\u057d"],["\u054e","\u057e"],["\u054f","\u057f"],["\u0550","\u0580"],["\u0551","\u0581"],["\u0552","\u0582"],["\u0553","\u0583"],["\u0554","\u0584"],["\u0555","\u0585"],["\u0556","\u0586"],["\u0587","\u0565\u0582"],["\u0591",""],["\u0592",""],["\u0593",""],["\u0594",""],["\u0595",""],["\u0596",""],["\u0597",""],["\u0598",""],["\u0599",""],["\u059a",""],["\u059b",""],["\u059c",""],["\u059d",""],["\u059e",""],["\u059f",""],["\u05a0",""],["\u05a1",""],["\u05a2",""],["\u05a3",""],["\u05a4",""],["\u05a5",""],["\u05a6",""],["\u05a7",""],["\u05a8",""],["\u05a9",""],["\u05aa",""],["\u05ab",""],["\u05ac",""],["\u05ad",""],["\u05ae",""],["\u05af",""],["\u05b0",""],["\u05b1",""],["\u05b2",""],["\u05b3",""],["\u05b4",""],["\u05b5",""],["\u05b6",""],["\u05b7",""],["\u05b8",""],["\u05b9",""],["\u05ba",""],["\u05bb",""],["\u05bc",""],["\u05bd",""],["\u05bf",""],["\u05c1",""],["\u05c2",""],["\u05c4",""],["\u05c5",""],["\u05c7",""],["\u0610",""],["\u0611",""],["\u0612",""],["\u0613",""],["\u0614",""],["\u0615",""],["\u0616",""],["\u0617",""],["\u0618",""],["\u0619",""],["\u061a",""],["\u0622","\u0627"],["\u0623","\u0627"],["\u0624","\u0648"],["\u0625","\u0627"],["\u0626","\u064a"],["\u064b",""],["\u064c",""],["\u064d",""],["\u064e",""],["\u064f",""],["\u0650",""],["\u0651",""],["\u0652",""],["\u0653",""],["\u0654",""],["\u0655",""],["\u0656",""],["\u0657",""],["\u0658",""],["\u0659",""],["\u065a",""],["\u065b",""],["\u065c",""],["\u065d",""],["\u065e",""],["\u065f",""],["\u0670",""],["\u0675","\u0627\u0674"],["\u0676","\u0648\u0674"],["\u0677","\u06c7\u0674"],["\u0678","\u064a\u0674"],["\u06c0","\u06d5"],["\u06c2","\u06c1"],["\u06d3","\u06d2"],["\u06d6",""],["\u06d7",""],["\u06d8",""],["\u06d9",""],["\u06da",""],["\u06db",""],["\u06dc",""],["\u06df",""],["\u06e0",""],["\u06e1",""],["\u06e2",""],["\u06e3",""],["\u06e4",""],["\u06e7",""],["\u06e8",""],["\u06ea",""],["\u06eb",""],["\u06ec",""],["\u06ed",""],["\u0711",""],["\u0730",""],["\u0731",""],["\u0732",""],["\u0733",""],["\u0734",""],["\u0735",""],["\u0736",""],["\u0737",""],["\u0738",""],["\u0739",""],["\u073a",""],["\u073b",""],["\u073c",""],["\u073d",""],["\u073e",""],["\u073f",""],["\u0740",""],["\u0741",""],["\u0742",""],["\u0743",""],["\u0744",""],["\u0745",""],["\u0746",""],["\u0747",""],["\u0748",""],["\u0749",""],["\u074a",""],["\u07eb",""],["\u07ec",""],["\u07ed",""],["\u07ee",""],["\u07ef",""],["\u07f0",""],["\u07f1",""],["\u07f2",""],["\u07f3",""],["\u07fd",""],["\u0816",""],["\u0817",""],["\u0818",""],["\u0819",""],["\u081b",""],["\u081c",""],["\u081d",""],["\u081e",""],["\u081f",""],["\u0820",""],["\u0821",""],["\u0822",""],["\u0823",""],["\u0825",""],["\u0826",""],["\u0827",""],["\u0829",""],["\u082a",""],["\u082b",""],["\u082c",""],["\u082d",""],["\u0859",""],["\u085a",""],["\u085b",""],["\u0897",""],["\u0898",""],["\u0899",""],["\u089a",""],["\u089b",""],["\u089c",""],["\u089d",""],["\u089e",""],["\u089f",""],["\u08ca",""],["\u08cb",""],["\u08cc",""],["\u08cd",""],["\u08ce",""],["\u08cf",""],["\u08d0",""],["\u08d1",""],["\u08d2",""],["\u08d3",""],["\u08d4",""],["\u08d5",""],["\u08d6",""],["\u08d7",""],["\u08d8",""],["\u08d9",""],["\u08da",""],["\u08db",""],["\u08dc",""],["\u08dd",""],["\u08de",""],["\u08df",""],["\u08e0",""],["\u08e1",""],["\u08e3",""],["\u08e4",""],["\u08e5",""],["\u08e6",""],["\u08e7",""],["\u08e8",""],["\u08e9",""],["\u08ea",""],["\u08eb",""],["\u08ec",""],["\u08ed",""],["\u08ee",""],["\u08ef",""],["\u08f0",""],["\u08f1",""],["\u08f2",""],["\u08f3",""],["\u08f4",""],["\u08f5",""],["\u08f6",""],["\u08f7",""],["\u08f8",""],["\u08f9",""],["\u08fa",""],["\u08fb",""],["\u08fc",""],["\u08fd",""],["\u08fe",""],["\u08ff",""],["\u0929","\u0928"],["\u0931","\u0930"],["\u0934","\u0933"],["\u093c",""],["\u094d",""],["\u0951",""],["\u0952",""],["\u0953",""],["\u0954",""],["\u0958","\u0915"],["\u0959","\u0916"],["\u095a","\u0917"],["\u095b","\u091c"],["\u095c","\u0921"],["\u095d","\u0922"],["\u095e","\u092b"],["\u095f","\u092f"],["\u09bc",""],["\u09cb","\u09c7\u09be"],["\u09cc","\u09c7\u09d7"],["\u09cd",""],["\u09dc","\u09a1"],["\u09dd","\u09a2"],["\u09df","\u09af"],["\u09fe",""],["\u0a33","\u0a32"],["\u0a36","\u0a38"],["\u0a3c",""],["\u0a4d",""],["\u0a59","\u0a16"],["\u0a5a","\u0a17"],["\u0a5b","\u0a1c"],["\u0a5e","\u0a2b"],["\u0abc",""],["\u0acd",""],["\u0b3c",""],["\u0b48","\u0b47\u0b56"],["\u0b4b","\u0b47\u0b3e"],["\u0b4c","\u0b47\u0b57"],["\u0b4d",""],["\u0b5c","\u0b21"],["\u0b5d","\u0b22"],["\u0b94","\u0b92\u0bd7"],["\u0bca","\u0bc6\u0bbe"],["\u0bcb","\u0bc7\u0bbe"],["\u0bcc","\u0bc6\u0bd7"],["\u0bcd",""],["\u0c3c",""],["\u0c48","\u0c46"],["\u0c4d",""],["\u0c55",""],["\u0c56",""],["\u0cbc",""],["\u0cc0","\u0cbf\u0cd5"],["\u0cc7","\u0cc6\u0cd5"],["\u0cc8","\u0cc6\u0cd6"],["\u0cca","\u0cc6\u0cc2"],["\u0ccb","\u0cc6\u0cc2\u0cd5"],["\u0ccd",""],["\u0d3b",""],["\u0d3c",""],["\u0d4a","\u0d46\u0d3e"],["\u0d4b","\u0d47\u0d3e"],["\u0d4c","\u0d46\u0d57"],["\u0d4d",""],["\u0dca",""],["\u0dda","\u0dd9"],["\u0ddc","\u0dd9\u0dcf"],["\u0ddd","\u0dd9\u0dcf"],["\u0dde","\u0dd9\u0ddf"],["\u0e33","\u0e4d\u0e32"],["\u0e38",""],["\u0e39",""],["\u0e3a",""],["\u0e48",""],["\u0e49",""],["\u0e4a",""],["\u0e4b",""],["\u0eb3","\u0ecd\u0eb2"],["\u0eb8",""],["\u0eb9",""],["\u0eba",""],["\u0ec8",""],["\u0ec9",""],["\u0eca",""],["\u0ecb",""],["\u0edc","\u0eab\u0e99"],["\u0edd","\u0eab\u0ea1"],["\u0f0c","\u0f0b"],["\u0f18",""],["\u0f19",""],["\u0f35",""],["\u0f37",""],["\u0f39",""],["\u0f43","\u0f42\u0fb7"],["\u0f4d","\u0f4c\u0fb7"],["\u0f52","\u0f51\u0fb7"],["\u0f57","\u0f56\u0fb7"],["\u0f5c","\u0f5b\u0fb7"],["\u0f69","\u0f40\u0fb5"],["\u0f71",""],["\u0f72",""],["\u0f73",""],["\u0f74",""],["\u0f75",""],["\u0f76","\u0fb2"],["\u0f77","\u0fb2"],["\u0f78","\u0fb3"],["\u0f79","\u0fb3"],["\u0f7a",""],["\u0f7b",""],["\u0f7c",""],["\u0f7d",""],["\u0f80",""],["\u0f81",""],["\u0f82",""],["\u0f83",""],["\u0f84",""],["\u0f86",""],["\u0f87",""],["\u0f93","\u0f92\u0fb7"],["\u0f9d","\u0f9c\u0fb7"],["\u0fa2","\u0fa1\u0fb7"],["\u0fa7","\u0fa6\u0fb7"],["\u0fac","\u0fab\u0fb7"],["\u0fb9","\u0f90\u0fb5"],["\u0fc6",""],["\u1026","\u1025\u102e"],["\u1037",""],["\u1039",""],["\u103a",""],["\u108d",""],["\u10a0","\u2d00"],["\u10a1","\u2d01"],["\u10a2","\u2d02"],["\u10a3","\u2d03"],["\u10a4","\u2d04"],["\u10a5","\u2d05"],["\u10a6","\u2d06"],["\u10a7","\u2d07"],["\u10a8","\u2d08"],["\u10a9","\u2d09"],["\u10aa","\u2d0a"],["\u10ab","\u2d0b"],["\u10ac","\u2d0c"],["\u10ad","\u2d0d"],["\u10ae","\u2d0e"],["\u10af","\u2d0f"],["\u10b0","\u2d10"],["\u10b1","\u2d11"],["\u10b2","\u2d12"],["\u10b3","\u2d13"],["\u10b4","\u2d14"],["\u10b5","\u2d15"],["\u10b6","\u2d16"],["\u10b7","\u2d17"],["\u10b8","\u2d18"],["\u10b9","\u2d19"],["\u10ba","\u2d1a"],["\u10bb","\u2d1b"],["\u10bc","\u2d1c"],["\u10bd","\u2d1d"],["\u10be","\u2d1e"],["\u10bf","\u2d1f"],["\u10c0","\u2d20"],["\u10c1","\u2d21"],["\u10c2","\u2d22"],["\u10c3","\u2d23"],["\u10c4","\u2d24"],["\u10c5","\u2d25"],["\u10c7","\u2d27"],["\u10cd","\u2d2d"],["\u10fc","\u10dc"],["\u135d",""],["\u135e",""],["\u135f",""],["\u13f8","\u13f0"],["\u13f9","\u13f1"],["\u13fa","\u13f2"],["\u13fb","\u13f3"],["\u13fc","\u13f4"],["\u13fd","\u13f5"],["\u1714",""],["\u1715",""],["\u1734",""],["\u17d2",""],["\u17dd",""],["\u18a9",""],["\u1939",""],["\u193a",""],["\u193b",""],["\u1a17",""],["\u1a18",""],["\u1a60",""],["\u1a75",""],["\u1a76",""],["\u1a77",""],["\u1a78",""],["\u1a79",""],["\u1a7a",""],["\u1a7b",""],["\u1a7c",""],["\u1a7f",""],["\u1ab0",""],["\u1ab1",""],["\u1ab2",""],["\u1ab3",""],["\u1ab4",""],["\u1ab5",""],["\u1ab6",""],["\u1ab7",""],["\u1ab8",""],["\u1ab9",""],["\u1aba",""],["\u1abb",""],["\u1abc",""],["\u1abd",""],["\u1abf",""],["\u1ac0",""],["\u1ac1",""],["\u1ac2",""],["\u1ac3",""],["\u1ac4",""],["\u1ac5",""],["\u1ac6",""],["\u1ac7",""],["\u1ac8",""],["\u1ac9",""],["\u1aca",""],["\u1acb",""],["\u1acc",""],["\u1acd",""],["\u1ace",""],["\u1b06","\u1b05\u1b35"],["\u1b08","\u1b07\u1b35"],["\u1b0a","\u1b09\u1b35"],["\u1b0c","\u1b0b\u1b35"],["\u1b0e","\u1b0d\u1b35"],["\u1b12","\u1b11\u1b35"],["\u1b34",""],["\u1b3b","\u1b3a\u1b35"],["\u1b3d","\u1b3c\u1b35"],["\u1b40","\u1b3e\u1b35"],["\u1b41","\u1b3f\u1b35"],["\u1b43","\u1b42\u1b35"],["\u1b44",""],["\u1b6b",""],["\u1b6c",""],["\u1b6d",""],["\u1b6e",""],["\u1b6f",""],["\u1b70",""],["\u1b71",""],["\u1b72",""],["\u1b73",""],["\u1baa",""],["\u1bab",""],["\u1be6",""],["\u1bf2",""],["\u1bf3",""],["\u1c37",""],["\u1c80","\u0432"],["\u1c81","\u0434"],["\u1c82","\u043e"],["\u1c83","\u0441"],["\u1c84","\u0442"],["\u1c85","\u0442"],["\u1c86","\u044a"],["\u1c87","\u0463"],["\u1c88","\ua64b"],["\u1c89","\u1c8a"],["\u1c90","\u10d0"],["\u1c91","\u10d1"],["\u1c92","\u10d2"],["\u1c93","\u10d3"],["\u1c94","\u10d4"],["\u1c95","\u10d5"],["\u1c96","\u10d6"],["\u1c97","\u10d7"],["\u1c98","\u10d8"],["\u1c99","\u10d9"],["\u1c9a","\u10da"],["\u1c9b","\u10db"],["\u1c9c","\u10dc"],["\u1c9d","\u10dd"],["\u1c9e","\u10de"],["\u1c9f","\u10df"],["\u1ca0","\u10e0"],["\u1ca1","\u10e1"],["\u1ca2","\u10e2"],["\u1ca3","\u10e3"],["\u1ca4","\u10e4"],["\u1ca5","\u10e5"],["\u1ca6","\u10e6"],["\u1ca7","\u10e7"],["\u1ca8","\u10e8"],["\u1ca9","\u10e9"],["\u1caa","\u10ea"],["\u1cab","\u10eb"],["\u1cac","\u10ec"],["\u1cad","\u10ed"],["\u1cae","\u10ee"],["\u1caf","\u10ef"],["\u1cb0","\u10f0"],["\u1cb1","\u10f1"],["\u1cb2","\u10f2"],["\u1cb3","\u10f3"],["\u1cb4","\u10f4"],["\u1cb5","\u10f5"],["\u1cb6","\u10f6"],["\u1cb7","\u10f7"],["\u1cb8","\u10f8"],["\u1cb9","\u10f9"],["\u1cba","\u10fa"],["\u1cbd","\u10fd"],["\u1cbe","\u10fe"],["\u1cbf","\u10ff"],["\u1cd0",""],["\u1cd1",""],["\u1cd2",""],["\u1cd4",""],["\u1cd5",""],["\u1cd6",""],["\u1cd7",""],["\u1cd8",""],["\u1cd9",""],["\u1cda",""],["\u1cdb",""],["\u1cdc",""],["\u1cdd",""],["\u1cde",""],["\u1cdf",""],["\u1ce0",""],["\u1ce2",""],["\u1ce3",""],["\u1ce4",""],["\u1ce5",""],["\u1ce6",""],["\u1ce7",""],["\u1ce8",""],["\u1ced",""],["\u1cf4",""],["\u1cf8",""],["\u1cf9",""],["\u1d2c","a"],["\u1d2d","\u00e6"],["\u1d2e","b"],["\u1d30","d"],["\u1d31","e"],["\u1d32","\u01dd"],["\u1d33","g"],["\u1d34","h"],["\u1d35","i"],["\u1d36","j"],["\u1d37","k"],["\u1d38","l"],["\u1d39","m"],["\u1d3a","n"],["\u1d3c","o"],["\u1d3d","\u0223"],["\u1d3e","p"],["\u1d3f","r"],["\u1d40","t"],["\u1d41","u"],["\u1d42","w"],["\u1d43","a"],["\u1d44","\u0250"],["\u1d45","\u0251"],["\u1d46","\u1d02"],["\u1d47","b"],["\u1d48","d"],["\u1d49","e"],["\u1d4a","\u0259"],["\u1d4b","\u025b"],["\u1d4c","\u025c"],["\u1d4d","g"],["\u1d4f","k"],["\u1d50","m"],["\u1d51","\u014b"],["\u1d52","o"],["\u1d53","\u0254"],["\u1d54","\u1d16"],["\u1d55","\u1d17"],["\u1d56","p"],["\u1d57","t"],["\u1d58","u"],["\u1d59","\u1d1d"],["\u1d5a","\u026f"],["\u1d5b","v"],["\u1d5c","\u1d25"],["\u1d5d","\u03b2"],["\u1d5e","\u03b3"],["\u1d5f","\u03b4"],["\u1d60","\u03c6"],["\u1d61","\u03c7"],["\u1d62","i"],["\u1d63","r"],["\u1d64","u"],["\u1d65","v"],["\u1d66","\u03b2"],["\u1d67","\u03b3"],["\u1d68","\u03c1"],["\u1d69","\u03c6"],["\u1d6a","\u03c7"],["\u1d78","\u043d"],["\u1d9b","\u0252"],["\u1d9c","c"],["\u1d9d","\u0255"],["\u1d9e","\u00f0"],["\u1d9f","\u025c"],["\u1da0","f"],["\u1da1","\u025f"],["\u1da2","\u0261"],["\u1da3","\u0265"],["\u1da4","\u0268"],["\u1da5","\u0269"],["\u1da6","\u026a"],["\u1da7","\u1d7b"],["\u1da8","\u029d"],["\u1da9","\u026d"],["\u1daa","\u1d85"],["\u1dab","\u029f"],["\u1dac","\u0271"],["\u1dad","\u0270"],["\u1dae","\u0272"],["\u1daf","\u0273"],["\u1db0","\u0274"],["\u1db1","\u0275"],["\u1db2","\u0278"],["\u1db3","\u0282"],["\u1db4","\u0283"],["\u1db5","\u01ab"],["\u1db6","\u0289"],["\u1db7","\u028a"],["\u1db8","\u1d1c"],["\u1db9","\u028b"],["\u1dba","\u028c"],["\u1dbb","z"],["\u1dbc","\u0290"],["\u1dbd","\u0291"],["\u1dbe","\u0292"],["\u1dbf","\u03b8"],["\u1dc0",""],["\u1dc1",""],["\u1dc2",""],["\u1dc3",""],["\u1dc4",""],["\u1dc5",""],["\u1dc6",""],["\u1dc7",""],["\u1dc8",""],["\u1dc9",""],["\u1dca",""],["\u1dcb",""],["\u1dcc",""],["\u1dcd",""],["\u1dce",""],["\u1dcf",""],["\u1dd0",""],["\u1dd1",""],["\u1dd2",""],["\u1dd3",""],["\u1dd4",""],["\u1dd5",""],["\u1dd6",""],["\u1dd7",""],["\u1dd8",""],["\u1dd9",""],["\u1dda",""],["\u1ddb",""],["\u1ddc",""],["\u1ddd",""],["\u1dde",""],["\u1ddf",""],["\u1de0",""],["\u1de1",""],["\u1de2",""],["\u1de3",""],["\u1de4",""],["\u1de5",""],["\u1de6",""],["\u1de7",""],["\u1de8",""],["\u1de9",""],["\u1dea",""],["\u1deb",""],["\u1dec",""],["\u1ded",""],["\u1dee",""],["\u1def",""],["\u1df0",""],["\u1df1",""],["\u1df2",""],["\u1df3",""],["\u1df4",""],["\u1df5",""],["\u1df6",""],["\u1df7",""],["\u1df8",""],["\u1df9",""],["\u1dfa",""],["\u1dfb",""],["\u1dfc",""],["\u1dfd",""],["\u1dfe",""],["\u1dff",""],["\u1e00","a"],["\u1e01","a"],["\u1e02","b"],["\u1e03","b"],["\u1e04","b"],["\u1e05","b"],["\u1e06","b"],["\u1e07","b"],["\u1e08","c"],["\u1e09","c"],["\u1e0a","d"],["\u1e0b","d"],["\u1e0c","d"],["\u1e0d","d"],["\u1e0e","d"],["\u1e0f","d"],["\u1e10","d"],["\u1e11","d"],["\u1e12","d"],["\u1e13","d"],["\u1e14","e"],["\u1e15","e"],["\u1e16","e"],["\u1e17","e"],["\u1e18","e"],["\u1e19","e"],["\u1e1a","e"],["\u1e1b","e"],["\u1e1c","e"],["\u1e1d","e"],["\u1e1e","f"],["\u1e1f","f"],["\u1e20","g"],["\u1e21","g"],["\u1e22","h"],["\u1e23","h"],["\u1e24","h"],["\u1e25","h"],["\u1e26","h"],["\u1e27","h"],["\u1e28","h"],["\u1e29","h"],["\u1e2a","h"],["\u1e2b","h"],["\u1e2c","i"],["\u1e2d","i"],["\u1e2e","i"],["\u1e2f","i"],["\u1e30","k"],["\u1e31","k"],["\u1e32","k"],["\u1e33","k"],["\u1e34","k"],["\u1e35","k"],["\u1e36","l"],["\u1e37","l"],["\u1e38","l"],["\u1e39","l"],["\u1e3a","l"],["\u1e3b","l"],["\u1e3c","l"],["\u1e3d","l"],["\u1e3e","m"],["\u1e3f","m"],["\u1e40","m"],["\u1e41","m"],["\u1e42","m"],["\u1e43","m"],["\u1e44","n"],["\u1e45","n"],["\u1e46","n"],["\u1e47","n"],["\u1e48","n"],["\u1e49","n"],["\u1e4a","n"],["\u1e4b","n"],["\u1e4c","o"],["\u1e4d","o"],["\u1e4e","o"],["\u1e4f","o"],["\u1e50","o"],["\u1e51","o"],["\u1e52","o"],["\u1e53","o"],["\u1e54","p"],["\u1e55","p"],["\u1e56","p"],["\u1e57","p"],["\u1e58","r"],["\u1e59","r"],["\u1e5a","r"],["\u1e5b","r"],["\u1e5c","r"],["\u1e5d","r"],["\u1e5e","r"],["\u1e5f","r"],["\u1e60","s"],["\u1e61","s"],["\u1e62","s"],["\u1e63","s"],["\u1e64","s"],["\u1e65","s"],["\u1e66","s"],["\u1e67","s"],["\u1e68","s"],["\u1e69","s"],["\u1e6a","t"],["\u1e6b","t"],["\u1e6c","t"],["\u1e6d","t"],["\u1e6e","t"],["\u1e6f","t"],["\u1e70","t"],["\u1e71","t"],["\u1e72","u"],["\u1e73","u"],["\u1e74","u"],["\u1e75","u"],["\u1e76","u"],["\u1e77","u"],["\u1e78","u"],["\u1e79","u"],["\u1e7a","u"],["\u1e7b","u"],["\u1e7c","v"],["\u1e7d","v"],["\u1e7e","v"],["\u1e7f","v"],["\u1e80","w"],["\u1e81","w"],["\u1e82","w"],["\u1e83","w"],["\u1e84","w"],["\u1e85","w"],["\u1e86","w"],["\u1e87","w"],["\u1e88","w"],["\u1e89","w"],["\u1e8a","x"],["\u1e8b","x"],["\u1e8c","x"],["\u1e8d","x"],["\u1e8e","y"],["\u1e8f","y"],["\u1e90","z"],["\u1e91","z"],["\u1e92","z"],["\u1e93","z"],["\u1e94","z"],["\u1e95","z"],["\u1e96","h"],["\u1e97","t"],["\u1e98","w"],["\u1e99","y"],["\u1e9a","a\u02be"],["\u1e9b","s"],["\u1e9e","ss"],["\u1ea0","a"],["\u1ea1","a"],["\u1ea2","a"],["\u1ea3","a"],["\u1ea4","a"],["\u1ea5","a"],["\u1ea6","a"],["\u1ea7","a"],["\u1ea8","a"],["\u1ea9","a"],["\u1eaa","a"],["\u1eab","a"],["\u1eac","a"],["\u1ead","a"],["\u1eae","a"],["\u1eaf","a"],["\u1eb0","a"],["\u1eb1","a"],["\u1eb2","a"],["\u1eb3","a"],["\u1eb4","a"],["\u1eb5","a"],["\u1eb6","a"],["\u1eb7","a"],["\u1eb8","e"],["\u1eb9","e"],["\u1eba","e"],["\u1ebb","e"],["\u1ebc","e"],["\u1ebd","e"],["\u1ebe","e"],["\u1ebf","e"],["\u1ec0","e"],["\u1ec1","e"],["\u1ec2","e"],["\u1ec3","e"],["\u1ec4","e"],["\u1ec5","e"],["\u1ec6","e"],["\u1ec7","e"],["\u1ec8","i"],["\u1ec9","i"],["\u1eca","i"],["\u1ecb","i"],["\u1ecc","o"],["\u1ecd","o"],["\u1ece","o"],["\u1ecf","o"],["\u1ed0","o"],["\u1ed1","o"],["\u1ed2","o"],["\u1ed3","o"],["\u1ed4","o"],["\u1ed5","o"],["\u1ed6","o"],["\u1ed7","o"],["\u1ed8","o"],["\u1ed9","o"],["\u1eda","o"],["\u1edb","o"],["\u1edc","o"],["\u1edd","o"],["\u1ede","o"],["\u1edf","o"],["\u1ee0","o"],["\u1ee1","o"],["\u1ee2","o"],["\u1ee3","o"],["\u1ee4","u"],["\u1ee5","u"],["\u1ee6","u"],["\u1ee7","u"],["\u1ee8","u"],["\u1ee9","u"],["\u1eea","u"],["\u1eeb","u"],["\u1eec","u"],["\u1eed","u"],["\u1eee","u"],["\u1eef","u"],["\u1ef0","u"],["\u1ef1","u"],["\u1ef2","y"],["\u1ef3","y"],["\u1ef4","y"],["\u1ef5","y"],["\u1ef6","y"],["\u1ef7","y"],["\u1ef8","y"],["\u1ef9","y"],["\u1efa","\u1efb"],["\u1efc","\u1efd"],["\u1efe","\u1eff"],["\u1f00","\u03b1"],["\u1f01","\u03b1"],["\u1f02","\u03b1"],["\u1f03","\u03b1"],["\u1f04","\u03b1"],["\u1f05","\u03b1"],["\u1f06","\u03b1"],["\u1f07","\u03b1"],["\u1f08","\u03b1"],["\u1f09","\u03b1"],["\u1f0a","\u03b1"],["\u1f0b","\u03b1"],["\u1f0c","\u03b1"],["\u1f0d","\u03b1"],["\u1f0e","\u03b1"],["\u1f0f","\u03b1"],["\u1f10","\u03b5"],["\u1f11","\u03b5"],["\u1f12","\u03b5"],["\u1f13","\u03b5"],["\u1f14","\u03b5"],["\u1f15","\u03b5"],["\u1f18","\u03b5"],["\u1f19","\u03b5"],["\u1f1a","\u03b5"],["\u1f1b","\u03b5"],["\u1f1c","\u03b5"],["\u1f1d","\u03b5"],["\u1f20","\u03b7"],["\u1f21","\u03b7"],["\u1f22","\u03b7"],["\u1f23","\u03b7"],["\u1f24","\u03b7"],["\u1f25","\u03b7"],["\u1f26","\u03b7"],["\u1f27","\u03b7"],["\u1f28","\u03b7"],["\u1f29","\u03b7"],["\u1f2a","\u03b7"],["\u1f2b","\u03b7"],["\u1f2c","\u03b7"],["\u1f2d","\u03b7"],["\u1f2e","\u03b7"],["\u1f2f","\u03b7"],["\u1f30","\u03b9"],["\u1f31","\u03b9"],["\u1f32","\u03b9"],["\u1f33","\u03b9"],["\u1f34","\u03b9"],["\u1f35","\u03b9"],["\u1f36","\u03b9"],["\u1f37","\u03b9"],["\u1f38","\u03b9"],["\u1f39","\u03b9"],["\u1f3a","\u03b9"],["\u1f3b","\u03b9"],["\u1f3c","\u03b9"],["\u1f3d","\u03b9"],["\u1f3e","\u03b9"],["\u1f3f","\u03b9"],["\u1f40","\u03bf"],["\u1f41","\u03bf"],["\u1f42","\u03bf"],["\u1f43","\u03bf"],["\u1f44","\u03bf"],["\u1f45","\u03bf"],["\u1f48","\u03bf"],["\u1f49","\u03bf"],["\u1f4a","\u03bf"],["\u1f4b","\u03bf"],["\u1f4c","\u03bf"],["\u1f4d","\u03bf"],["\u1f50","\u03c5"],["\u1f51","\u03c5"],["\u1f52","\u03c5"],["\u1f53","\u03c5"],["\u1f54","\u03c5"],["\u1f55","\u03c5"],["\u1f56","\u03c5"],["\u1f57","\u03c5"],["\u1f59","\u03c5"],["\u1f5b","\u03c5"],["\u1f5d","\u03c5"],["\u1f5f","\u03c5"],["\u1f60","\u03c9"],["\u1f61","\u03c9"],["\u1f62","\u03c9"],["\u1f63","\u03c9"],["\u1f64","\u03c9"],["\u1f65","\u03c9"],["\u1f66","\u03c9"],["\u1f67","\u03c9"],["\u1f68","\u03c9"],["\u1f69","\u03c9"],["\u1f6a","\u03c9"],["\u1f6b","\u03c9"],["\u1f6c","\u03c9"],["\u1f6d","\u03c9"],["\u1f6e","\u03c9"],["\u1f6f","\u03c9"],["\u1f70","\u03b1"],["\u1f71","\u03b1"],["\u1f72","\u03b5"],["\u1f73","\u03b5"],["\u1f74","\u03b7"],["\u1f75","\u03b7"],["\u1f76","\u03b9"],["\u1f77","\u03b9"],["\u1f78","\u03bf"],["\u1f79","\u03bf"],["\u1f7a","\u03c5"],["\u1f7b","\u03c5"],["\u1f7c","\u03c9"],["\u1f7d","\u03c9"],["\u1f80","\u03b1"],["\u1f81","\u03b1"],["\u1f82","\u03b1"],["\u1f83","\u03b1"],["\u1f84","\u03b1"],["\u1f85","\u03b1"],["\u1f86","\u03b1"],["\u1f87","\u03b1"],["\u1f88","\u03b1"],["\u1f89","\u03b1"],["\u1f8a","\u03b1"],["\u1f8b","\u03b1"],["\u1f8c","\u03b1"],["\u1f8d","\u03b1"],["\u1f8e","\u03b1"],["\u1f8f","\u03b1"],["\u1f90","\u03b7"],["\u1f91","\u03b7"],["\u1f92","\u03b7"],["\u1f93","\u03b7"],["\u1f94","\u03b7"],["\u1f95","\u03b7"],["\u1f96","\u03b7"],["\u1f97","\u03b7"],["\u1f98","\u03b7"],["\u1f99","\u03b7"],["\u1f9a","\u03b7"],["\u1f9b","\u03b7"],["\u1f9c","\u03b7"],["\u1f9d","\u03b7"],["\u1f9e","\u03b7"],["\u1f9f","\u03b7"],["\u1fa0","\u03c9"],["\u1fa1","\u03c9"],["\u1fa2","\u03c9"],["\u1fa3","\u03c9"],["\u1fa4","\u03c9"],["\u1fa5","\u03c9"],["\u1fa6","\u03c9"],["\u1fa7","\u03c9"],["\u1fa8","\u03c9"],["\u1fa9","\u03c9"],["\u1faa","\u03c9"],["\u1fab","\u03c9"],["\u1fac","\u03c9"],["\u1fad","\u03c9"],["\u1fae","\u03c9"],["\u1faf","\u03c9"],["\u1fb0","\u03b1"],["\u1fb1","\u03b1"],["\u1fb2","\u03b1"],["\u1fb3","\u03b1"],["\u1fb4","\u03b1"],["\u1fb6","\u03b1"],["\u1fb7","\u03b1"],["\u1fb8","\u03b1"],["\u1fb9","\u03b1"],["\u1fba","\u03b1"],["\u1fbb","\u03b1"],["\u1fbc","\u03b1"],["\u1fbd"," "],["\u1fbe","\u03b9"],["\u1fbf"," "],["\u1fc0"," "],["\u1fc1"," "],["\u1fc2","\u03b7"],["\u1fc3","\u03b7"],["\u1fc4","\u03b7"],["\u1fc6","\u03b7"],["\u1fc7","\u03b7"],["\u1fc8","\u03b5"],["\u1fc9","\u03b5"],["\u1fca","\u03b7"],["\u1fcb","\u03b7"],["\u1fcc","\u03b7"],["\u1fcd"," "],["\u1fce"," "],["\u1fcf"," "],["\u1fd0","\u03b9"],["\u1fd1","\u03b9"],["\u1fd2","\u03b9"],["\u1fd3","\u03b9"],["\u1fd6","\u03b9"],["\u1fd7","\u03b9"],["\u1fd8","\u03b9"],["\u1fd9","\u03b9"],["\u1fda","\u03b9"],["\u1fdb","\u03b9"],["\u1fdd"," "],["\u1fde"," "],["\u1fdf"," "],["\u1fe0","\u03c5"],["\u1fe1","\u03c5"],["\u1fe2","\u03c5"],["\u1fe3","\u03c5"],["\u1fe4","\u03c1"],["\u1fe5","\u03c1"],["\u1fe6","\u03c5"],["\u1fe7","\u03c5"],["\u1fe8","\u03c5"],["\u1fe9","\u03c5"],["\u1fea","\u03c5"],["\u1feb","\u03c5"],["\u1fec","\u03c1"],["\u1fed"," "],["\u1fee"," "],["\u1fef","`"],["\u1ff2","\u03c9"],["\u1ff3","\u03c9"],["\u1ff4","\u03c9"],["\u1ff6","\u03c9"],["\u1ff7","\u03c9"],["\u1ff8","\u03bf"],["\u1ff9","\u03bf"],["\u1ffa","\u03c9"],["\u1ffb","\u03c9"],["\u1ffc","\u03c9"],["\u1ffd"," "],["\u1ffe"," "],["\u2000"," "],["\u2001"," "],["\u2002"," "],["\u2003"," "],["\u2004"," "],["\u2005"," "],["\u2006"," "],["\u2007"," "],["\u2008"," "],["\u2009"," "],["\u200a"," "],["\u2011","\u2010"],["\u2017"," "],["\u2024","."],["\u2025",".."],["\u2026","..."],["\u202f"," "],["\u2033","\u2032\u2032"],["\u2034","\u2032\u2032\u2032"],["\u2036","\u2035\u2035"],["\u2037","\u2035\u2035\u2035"],["\u203c","!!"],["\u203e"," "],["\u2047","??"],["\u2048","?!"],["\u2049","!?"],["\u2057","\u2032\u2032\u2032\u2032"],["\u205f"," "],["\u2070","0"],["\u2071","i"],["\u2074","4"],["\u2075","5"],["\u2076","6"],["\u2077","7"],["\u2078","8"],["\u2079","9"],["\u207a","+"],["\u207b","\u2212"],["\u207c","="],["\u207d","("],["\u207e",")"],["\u207f","n"],["\u2080","0"],["\u2081","1"],["\u2082","2"],["\u2083","3"],["\u2084","4"],["\u2085","5"],["\u2086","6"],["\u2087","7"],["\u2088","8"],["\u2089","9"],["\u208a","+"],["\u208b","\u2212"],["\u208c","="],["\u208d","("],["\u208e",")"],["\u2090","a"],["\u2091","e"],["\u2092","o"],["\u2093","x"],["\u2094","\u0259"],["\u2095","h"],["\u2096","k"],["\u2097","l"],["\u2098","m"],["\u2099","n"],["\u209a","p"],["\u209b","s"],["\u209c","t"],["\u20a8","rs"],["\u20d0",""],["\u20d1",""],["\u20d2",""],["\u20d3",""],["\u20d4",""],["\u20d5",""],["\u20d6",""],["\u20d7",""],["\u20d8",""],["\u20d9",""],["\u20da",""],["\u20db",""],["\u20dc",""],["\u20e1",""],["\u20e5",""],["\u20e6",""],["\u20e7",""],["\u20e8",""],["\u20e9",""],["\u20ea",""],["\u20eb",""],["\u20ec",""],["\u20ed",""],["\u20ee",""],["\u20ef",""],["\u20f0",""],["\u2100","a/c"],["\u2101","a/s"],["\u2102","c"],["\u2103","\u00b0c"],["\u2105","c/o"],["\u2106","c/u"],["\u2107","\u025b"],["\u2109","\u00b0f"],["\u210a","g"],["\u210b","h"],["\u210c","h"],["\u210d","h"],["\u210e","h"],["\u210f","\u0127"],["\u2110","i"],["\u2111","i"],["\u2112","l"],["\u2113","l"],["\u2115","n"],["\u2116","no"],["\u2119","p"],["\u211a","q"],["\u211b","r"],["\u211c","r"],["\u211d","r"],["\u2120","sm"],["\u2121","tel"],["\u2122","tm"],["\u2124","z"],["\u2126","\u03c9"],["\u2128","z"],["\u212a","k"],["\u212b","a"],["\u212c","b"],["\u212d","c"],["\u212f","e"],["\u2130","e"],["\u2131","f"],["\u2132","\u214e"],["\u2133","m"],["\u2134","o"],["\u2135","\u05d0"],["\u2136","\u05d1"],["\u2137","\u05d2"],["\u2138","\u05d3"],["\u2139","i"],["\u213b","fax"],["\u213c","\u03c0"],["\u213d","\u03b3"],["\u213e","\u03b3"],["\u213f","\u03c0"],["\u2140","\u2211"],["\u2145","d"],["\u2146","d"],["\u2147","e"],["\u2148","i"],["\u2149","j"],["\u2150","1\u20447"],["\u2151","1\u20449"],["\u2152","1\u204410"],["\u2153","1\u20443"],["\u2154","2\u20443"],["\u2155","1\u20445"],["\u2156","2\u20445"],["\u2157","3\u20445"],["\u2158","4\u20445"],["\u2159","1\u20446"],["\u215a","5\u20446"],["\u215b","1\u20448"],["\u215c","3\u20448"],["\u215d","5\u20448"],["\u215e","7\u20448"],["\u215f","1\u2044"],["\u2160","i"],["\u2161","ii"],["\u2162","iii"],["\u2163","iv"],["\u2164","v"],["\u2165","vi"],["\u2166","vii"],["\u2167","viii"],["\u2168","ix"],["\u2169","x"],["\u216a","xi"],["\u216b","xii"],["\u216c","l"],["\u216d","c"],["\u216e","d"],["\u216f","m"],["\u2170","i"],["\u2171","ii"],["\u2172","iii"],["\u2173","iv"],["\u2174","v"],["\u2175","vi"],["\u2176","vii"],["\u2177","viii"],["\u2178","ix"],["\u2179","x"],["\u217a","xi"],["\u217b","xii"],["\u217c","l"],["\u217d","c"],["\u217e","d"],["\u217f","m"],["\u2183","\u2184"],["\u2189","0\u20443"],["\u219a","\u2190"],["\u219b","\u2192"],["\u21ae","\u2194"],["\u21cd","\u21d0"],["\u21ce","\u21d4"],["\u21cf","\u21d2"],["\u2204","\u2203"],["\u2209","\u2208"],["\u220c","\u220b"],["\u2224","\u2223"],["\u2226","\u2225"],["\u222c","\u222b\u222b"],["\u222d","\u222b\u222b\u222b"],["\u222f","\u222e\u222e"],["\u2230","\u222e\u222e\u222e"],["\u2241","\u223c"],["\u2244","\u2243"],["\u2247","\u2245"],["\u2249","\u2248"],["\u2260","="],["\u2262","\u2261"],["\u226d","\u224d"],["\u226e","<"],["\u226f",">"],["\u2270","\u2264"],["\u2271","\u2265"],["\u2274","\u2272"],["\u2275","\u2273"],["\u2278","\u2276"],["\u2279","\u2277"],["\u2280","\u227a"],["\u2281","\u227b"],["\u2284","\u2282"],["\u2285","\u2283"],["\u2288","\u2286"],["\u2289","\u2287"],["\u22ac","\u22a2"],["\u22ad","\u22a8"],["\u22ae","\u22a9"],["\u22af","\u22ab"],["\u22e0","\u227c"],["\u22e1","\u227d"],["\u22e2","\u2291"],["\u22e3","\u2292"],["\u22ea","\u22b2"],["\u22eb","\u22b3"],["\u22ec","\u22b4"],["\u22ed","\u22b5"],["\u2329","\u3008"],["\u232a","\u3009"],["\u2460","1"],["\u2461","2"],["\u2462","3"],["\u2463","4"],["\u2464","5"],["\u2465","6"],["\u2466","7"],["\u2467","8"],["\u2468","9"],["\u2469","10"],["\u246a","11"],["\u246b","12"],["\u246c","13"],["\u246d","14"],["\u246e","15"],["\u246f","16"],["\u2470","17"],["\u2471","18"],["\u2472","19"],["\u2473","20"],["\u2474","(1)"],["\u2475","(2)"],["\u2476","(3)"],["\u2477","(4)"],["\u2478","(5)"],["\u2479","(6)"],["\u247a","(7)"],["\u247b","(8)"],["\u247c","(9)"],["\u247d","(10)"],["\u247e","(11)"],["\u247f","(12)"],["\u2480","(13)"],["\u2481","(14)"],["\u2482","(15)"],["\u2483","(16)"],["\u2484","(17)"],["\u2485","(18)"],["\u2486","(19)"],["\u2487","(20)"],["\u2488","1."],["\u2489","2."],["\u248a","3."],["\u248b","4."],["\u248c","5."],["\u248d","6."],["\u248e","7."],["\u248f","8."],["\u2490","9."],["\u2491","10."],["\u2492","11."],["\u2493","12."],["\u2494","13."],["\u2495","14."],["\u2496","15."],["\u2497","16."],["\u2498","17."],["\u2499","18."],["\u249a","19."],["\u249b","20."],["\u249c","(a)"],["\u249d","(b)"],["\u249e","(c)"],["\u249f","(d)"],["\u24a0","(e)"],["\u24a1","(f)"],["\u24a2","(g)"],["\u24a3","(h)"],["\u24a4","(i)"],["\u24a5","(j)"],["\u24a6","(k)"],["\u24a7","(l)"],["\u24a8","(m)"],["\u24a9","(n)"],["\u24aa","(o)"],["\u24ab","(p)"],["\u24ac","(q)"],["\u24ad","(r)"],["\u24ae","(s)"],["\u24af","(t)"],["\u24b0","(u)"],["\u24b1","(v)"],["\u24b2","(w)"],["\u24b3","(x)"],["\u24b4","(y)"],["\u24b5","(z)"],["\u24b6","a"],["\u24b7","b"],["\u24b8","c"],["\u24b9","d"],["\u24ba","e"],["\u24bb","f"],["\u24bc","g"],["\u24bd","h"],["\u24be","i"],["\u24bf","j"],["\u24c0","k"],["\u24c1","l"],["\u24c2","m"],["\u24c3","n"],["\u24c4","o"],["\u24c5","p"],["\u24c6","q"],["\u24c7","r"],["\u24c8","s"],["\u24c9","t"],["\u24ca","u"],["\u24cb","v"],["\u24cc","w"],["\u24cd","x"],["\u24ce","y"],["\u24cf","z"],["\u24d0","a"],["\u24d1","b"],["\u24d2","c"],["\u24d3","d"],["\u24d4","e"],["\u24d5","f"],["\u24d6","g"],["\u24d7","h"],["\u24d8","i"],["\u24d9","j"],["\u24da","k"],["\u24db","l"],["\u24dc","m"],["\u24dd","n"],["\u24de","o"],["\u24df","p"],["\u24e0","q"],["\u24e1","r"],["\u24e2","s"],["\u24e3","t"],["\u24e4","u"],["\u24e5","v"],["\u24e6","w"],["\u24e7","x"],["\u24e8","y"],["\u24e9","z"],["\u24ea","0"],["\u2a0c","\u222b\u222b\u222b\u222b"],["\u2a74","::="],["\u2a75","=="],["\u2a76","==="],["\u2adc","\u2add"],["\u2c00","\u2c30"],["\u2c01","\u2c31"],["\u2c02","\u2c32"],["\u2c03","\u2c33"],["\u2c04","\u2c34"],["\u2c05","\u2c35"],["\u2c06","\u2c36"],["\u2c07","\u2c37"],["\u2c08","\u2c38"],["\u2c09","\u2c39"],["\u2c0a","\u2c3a"],["\u2c0b","\u2c3b"],["\u2c0c","\u2c3c"],["\u2c0d","\u2c3d"],["\u2c0e","\u2c3e"],["\u2c0f","\u2c3f"],["\u2c10","\u2c40"],["\u2c11","\u2c41"],["\u2c12","\u2c42"],["\u2c13","\u2c43"],["\u2c14","\u2c44"],["\u2c15","\u2c45"],["\u2c16","\u2c46"],["\u2c17","\u2c47"],["\u2c18","\u2c48"],["\u2c19","\u2c49"],["\u2c1a","\u2c4a"],["\u2c1b","\u2c4b"],["\u2c1c","\u2c4c"],["\u2c1d","\u2c4d"],["\u2c1e","\u2c4e"],["\u2c1f","\u2c4f"],["\u2c20","\u2c50"],["\u2c21","\u2c51"],["\u2c22","\u2c52"],["\u2c23","\u2c53"],["\u2c24","\u2c54"],["\u2c25","\u2c55"],["\u2c26","\u2c56"],["\u2c27","\u2c57"],["\u2c28","\u2c58"],["\u2c29","\u2c59"],["\u2c2a","\u2c5a"],["\u2c2b","\u2c5b"],["\u2c2c","\u2c5c"],["\u2c2d","\u2c5d"],["\u2c2e","\u2c5e"],["\u2c2f","\u2c5f"],["\u2c60","\u2c61"],["\u2c62","\u026b"],["\u2c63","\u1d7d"],["\u2c64","\u027d"],["\u2c67","\u2c68"],["\u2c69","\u2c6a"],["\u2c6b","\u2c6c"],["\u2c6d","\u0251"],["\u2c6e","\u0271"],["\u2c6f","\u0250"],["\u2c70","\u0252"],["\u2c72","\u2c73"],["\u2c75","\u2c76"],["\u2c7c","j"],["\u2c7d","v"],["\u2c7e","\u023f"],["\u2c7f","\u0240"],["\u2c80","\u2c81"],["\u2c82","\u2c83"],["\u2c84","\u2c85"],["\u2c86","\u2c87"],["\u2c88","\u2c89"],["\u2c8a","\u2c8b"],["\u2c8c","\u2c8d"],["\u2c8e","\u2c8f"],["\u2c90","\u2c91"],["\u2c92","\u2c93"],["\u2c94","\u2c95"],["\u2c96","\u2c97"],["\u2c98","\u2c99"],["\u2c9a","\u2c9b"],["\u2c9c","\u2c9d"],["\u2c9e","\u2c9f"],["\u2ca0","\u2ca1"],["\u2ca2","\u2ca3"],["\u2ca4","\u2ca5"],["\u2ca6","\u2ca7"],["\u2ca8","\u2ca9"],["\u2caa","\u2cab"],["\u2cac","\u2cad"],["\u2cae","\u2caf"],["\u2cb0","\u2cb1"],["\u2cb2","\u2cb3"],["\u2cb4","\u2cb5"],["\u2cb6","\u2cb7"],["\u2cb8","\u2cb9"],["\u2cba","\u2cbb"],["\u2cbc","\u2cbd"],["\u2cbe","\u2cbf"],["\u2cc0","\u2cc1"],["\u2cc2","\u2cc3"],["\u2cc4","\u2cc5"],["\u2cc6","\u2cc7"],["\u2cc8","\u2cc9"],["\u2cca","\u2ccb"],["\u2ccc","\u2ccd"],["\u2cce","\u2ccf"],["\u2cd0","\u2cd1"],["\u2cd2","\u2cd3"],["\u2cd4","\u2cd5"],["\u2cd6","\u2cd7"],["\u2cd8","\u2cd9"],["\u2cda","\u2cdb"],["\u2cdc","\u2cdd"],["\u2cde","\u2cdf"],["\u2ce0","\u2ce1"],["\u2ce2","\u2ce3"],["\u2ceb","\u2cec"],["\u2ced","\u2cee"],["\u2cef",""],["\u2cf0",""],["\u2cf1",""],["\u2cf2","\u2cf3"],["\u2d6f","\u2d61"],["\u2d7f",""],["\u2de0",""],["\u2de1",""],["\u2de2",""],["\u2de3",""],["\u2de4",""],["\u2de5",""],["\u2de6",""],["\u2de7",""],["\u2de8",""],["\u2de9",""],["\u2dea",""],["\u2deb",""],["\u2dec",""],["\u2ded",""],["\u2dee",""],["\u2def",""],["\u2df0",""],["\u2df1",""],["\u2df2",""],["\u2df3",""],["\u2df4",""],["\u2df5",""],["\u2df6",""],["\u2df7",""],["\u2df8",""],["\u2df9",""],["\u2dfa",""],["\u2dfb",""],["\u2dfc",""],["\u2dfd",""],["\u2dfe",""],["\u2dff",""],["\u2e9f","\u6bcd"],["\u2ef3","\u9f9f"],["\u2f00","\u4e00"],["\u2f01","\u4e28"],["\u2f02","\u4e36"],["\u2f03","\u4e3f"],["\u2f04","\u4e59"],["\u2f05","\u4e85"],["\u2f06","\u4e8c"],["\u2f07","\u4ea0"],["\u2f08","\u4eba"],["\u2f09","\u513f"],["\u2f0a","\u5165"],["\u2f0b","\u516b"],["\u2f0c","\u5182"],["\u2f0d","\u5196"],["\u2f0e","\u51ab"],["\u2f0f","\u51e0"],["\u2f10","\u51f5"],["\u2f11","\u5200"],["\u2f12","\u529b"],["\u2f13","\u52f9"],["\u2f14","\u5315"],["\u2f15","\u531a"],["\u2f16","\u5338"],["\u2f17","\u5341"],["\u2f18","\u535c"],["\u2f19","\u5369"],["\u2f1a","\u5382"],["\u2f1b","\u53b6"],["\u2f1c","\u53c8"],["\u2f1d","\u53e3"],["\u2f1e","\u56d7"],["\u2f1f","\u571f"],["\u2f20","\u58eb"],["\u2f21","\u5902"],["\u2f22","\u590a"],["\u2f23","\u5915"],["\u2f24","\u5927"],["\u2f25","\u5973"],["\u2f26","\u5b50"],["\u2f27","\u5b80"],["\u2f28","\u5bf8"],["\u2f29","\u5c0f"],["\u2f2a","\u5c22"],["\u2f2b","\u5c38"],["\u2f2c","\u5c6e"],["\u2f2d","\u5c71"],["\u2f2e","\u5ddb"],["\u2f2f","\u5de5"],["\u2f30","\u5df1"],["\u2f31","\u5dfe"],["\u2f32","\u5e72"],["\u2f33","\u5e7a"],["\u2f34","\u5e7f"],["\u2f35","\u5ef4"],["\u2f36","\u5efe"],["\u2f37","\u5f0b"],["\u2f38","\u5f13"],["\u2f39","\u5f50"],["\u2f3a","\u5f61"],["\u2f3b","\u5f73"],["\u2f3c","\u5fc3"],["\u2f3d","\u6208"],["\u2f3e","\u6236"],["\u2f3f","\u624b"],["\u2f40","\u652f"],["\u2f41","\u6534"],["\u2f42","\u6587"],["\u2f43","\u6597"],["\u2f44","\u65a4"],["\u2f45","\u65b9"],["\u2f46","\u65e0"],["\u2f47","\u65e5"],["\u2f48","\u66f0"],["\u2f49","\u6708"],["\u2f4a","\u6728"],["\u2f4b","\u6b20"],["\u2f4c","\u6b62"],["\u2f4d","\u6b79"],["\u2f4e","\u6bb3"],["\u2f4f","\u6bcb"],["\u2f50","\u6bd4"],["\u2f51","\u6bdb"],["\u2f52","\u6c0f"],["\u2f53","\u6c14"],["\u2f54","\u6c34"],["\u2f55","\u706b"],["\u2f56","\u722a"],["\u2f57","\u7236"],["\u2f58","\u723b"],["\u2f59","\u723f"],["\u2f5a","\u7247"],["\u2f5b","\u7259"],["\u2f5c","\u725b"],["\u2f5d","\u72ac"],["\u2f5e","\u7384"],["\u2f5f","\u7389"],["\u2f60","\u74dc"],["\u2f61","\u74e6"],["\u2f62","\u7518"],["\u2f63","\u751f"],["\u2f64","\u7528"],["\u2f65","\u7530"],["\u2f66","\u758b"],["\u2f67","\u7592"],["\u2f68","\u7676"],["\u2f69","\u767d"],["\u2f6a","\u76ae"],["\u2f6b","\u76bf"],["\u2f6c","\u76ee"],["\u2f6d","\u77db"],["\u2f6e","\u77e2"],["\u2f6f","\u77f3"],["\u2f70","\u793a"],["\u2f71","\u79b8"],["\u2f72","\u79be"],["\u2f73","\u7a74"],["\u2f74","\u7acb"],["\u2f75","\u7af9"],["\u2f76","\u7c73"],["\u2f77","\u7cf8"],["\u2f78","\u7f36"],["\u2f79","\u7f51"],["\u2f7a","\u7f8a"],["\u2f7b","\u7fbd"],["\u2f7c","\u8001"],["\u2f7d","\u800c"],["\u2f7e","\u8012"],["\u2f7f","\u8033"],["\u2f80","\u807f"],["\u2f81","\u8089"],["\u2f82","\u81e3"],["\u2f83","\u81ea"],["\u2f84","\u81f3"],["\u2f85","\u81fc"],["\u2f86","\u820c"],["\u2f87","\u821b"],["\u2f88","\u821f"],["\u2f89","\u826e"],["\u2f8a","\u8272"],["\u2f8b","\u8278"],["\u2f8c","\u864d"],["\u2f8d","\u866b"],["\u2f8e","\u8840"],["\u2f8f","\u884c"],["\u2f90","\u8863"],["\u2f91","\u897e"],["\u2f92","\u898b"],["\u2f93","\u89d2"],["\u2f94","\u8a00"],["\u2f95","\u8c37"],["\u2f96","\u8c46"],["\u2f97","\u8c55"],["\u2f98","\u8c78"],["\u2f99","\u8c9d"],["\u2f9a","\u8d64"],["\u2f9b","\u8d70"],["\u2f9c","\u8db3"],["\u2f9d","\u8eab"],["\u2f9e","\u8eca"],["\u2f9f","\u8f9b"],["\u2fa0","\u8fb0"],["\u2fa1","\u8fb5"],["\u2fa2","\u9091"],["\u2fa3","\u9149"],["\u2fa4","\u91c6"],["\u2fa5","\u91cc"],["\u2fa6","\u91d1"],["\u2fa7","\u9577"],["\u2fa8","\u9580"],["\u2fa9","\u961c"],["\u2faa","\u96b6"],["\u2fab","\u96b9"],["\u2fac","\u96e8"],["\u2fad","\u9751"],["\u2fae","\u975e"],["\u2faf","\u9762"],["\u2fb0","\u9769"],["\u2fb1","\u97cb"],["\u2fb2","\u97ed"],["\u2fb3","\u97f3"],["\u2fb4","\u9801"],["\u2fb5","\u98a8"],["\u2fb6","\u98db"],["\u2fb7","\u98df"],["\u2fb8","\u9996"],["\u2fb9","\u9999"],["\u2fba","\u99ac"],["\u2fbb","\u9aa8"],["\u2fbc","\u9ad8"],["\u2fbd","\u9adf"],["\u2fbe","\u9b25"],["\u2fbf","\u9b2f"],["\u2fc0","\u9b32"],["\u2fc1","\u9b3c"],["\u2fc2","\u9b5a"],["\u2fc3","\u9ce5"],["\u2fc4","\u9e75"],["\u2fc5","\u9e7f"],["\u2fc6","\u9ea5"],["\u2fc7","\u9ebb"],["\u2fc8","\u9ec3"],["\u2fc9","\u9ecd"],["\u2fca","\u9ed1"],["\u2fcb","\u9ef9"],["\u2fcc","\u9efd"],["\u2fcd","\u9f0e"],["\u2fce","\u9f13"],["\u2fcf","\u9f20"],["\u2fd0","\u9f3b"],["\u2fd1","\u9f4a"],["\u2fd2","\u9f52"],["\u2fd3","\u9f8d"],["\u2fd4","\u9f9c"],["\u2fd5","\u9fa0"],["\u3000"," "],["\u302a",""],["\u302b",""],["\u302c",""],["\u302d",""],["\u302e",""],["\u302f",""],["\u3036","\u3012"],["\u3038","\u5341"],["\u3039","\u5344"],["\u303a","\u5345"],["\u304c","\u304b"],["\u304e","\u304d"],["\u3050","\u304f"],["\u3052","\u3051"],["\u3054","\u3053"],["\u3056","\u3055"],["\u3058","\u3057"],["\u305a","\u3059"],["\u305c","\u305b"],["\u305e","\u305d"],["\u3060","\u305f"],["\u3062","\u3061"],["\u3065","\u3064"],["\u3067","\u3066"],["\u3069","\u3068"],["\u3070","\u306f"],["\u3071","\u306f"],["\u3073","\u3072"],["\u3074","\u3072"],["\u3076","\u3075"],["\u3077","\u3075"],["\u3079","\u3078"],["\u307a","\u3078"],["\u307c","\u307b"],["\u307d","\u307b"],["\u3094","\u3046"],["\u3099",""],["\u309a",""],["\u309b"," "],["\u309c"," "],["\u309e","\u309d"],["\u309f","\u3088\u308a"],["\u30ac","\u30ab"],["\u30ae","\u30ad"],["\u30b0","\u30af"],["\u30b2","\u30b1"],["\u30b4","\u30b3"],["\u30b6","\u30b5"],["\u30b8","\u30b7"],["\u30ba","\u30b9"],["\u30bc","\u30bb"],["\u30be","\u30bd"],["\u30c0","\u30bf"],["\u30c2","\u30c1"],["\u30c5","\u30c4"],["\u30c7","\u30c6"],["\u30c9","\u30c8"],["\u30d0","\u30cf"],["\u30d1","\u30cf"],["\u30d3","\u30d2"],["\u30d4","\u30d2"],["\u30d6","\u30d5"],["\u30d7","\u30d5"],["\u30d9","\u30d8"],["\u30da","\u30d8"],["\u30dc","\u30db"],["\u30dd","\u30db"],["\u30f4","\u30a6"],["\u30f7","\u30ef"],["\u30f8","\u30f0"],["\u30f9","\u30f1"],["\u30fa","\u30f2"],["\u30fe","\u30fd"],["\u30ff","\u30b3\u30c8"],["\u3131","\u1100"],["\u3132","\u1101"],["\u3133","\u11aa"],["\u3134","\u1102"],["\u3135","\u11ac"],["\u3136","\u11ad"],["\u3137","\u1103"],["\u3138","\u1104"],["\u3139","\u1105"],["\u313a","\u11b0"],["\u313b","\u11b1"],["\u313c","\u11b2"],["\u313d","\u11b3"],["\u313e","\u11b4"],["\u313f","\u11b5"],["\u3140","\u111a"],["\u3141","\u1106"],["\u3142","\u1107"],["\u3143","\u1108"],["\u3144","\u1121"],["\u3145","\u1109"],["\u3146","\u110a"],["\u3147","\u110b"],["\u3148","\u110c"],["\u3149","\u110d"],["\u314a","\u110e"],["\u314b","\u110f"],["\u314c","\u1110"],["\u314d","\u1111"],["\u314e","\u1112"],["\u314f","\u1161"],["\u3150","\u1162"],["\u3151","\u1163"],["\u3152","\u1164"],["\u3153","\u1165"],["\u3154","\u1166"],["\u3155","\u1167"],["\u3156","\u1168"],["\u3157","\u1169"],["\u3158","\u116a"],["\u3159","\u116b"],["\u315a","\u116c"],["\u315b","\u116d"],["\u315c","\u116e"],["\u315d","\u116f"],["\u315e","\u1170"],["\u315f","\u1171"],["\u3160","\u1172"],["\u3161","\u1173"],["\u3162","\u1174"],["\u3163","\u1175"],["\u3164","\u1160"],["\u3165","\u1114"],["\u3166","\u1115"],["\u3167","\u11c7"],["\u3168","\u11c8"],["\u3169","\u11cc"],["\u316a","\u11ce"],["\u316b","\u11d3"],["\u316c","\u11d7"],["\u316d","\u11d9"],["\u316e","\u111c"],["\u316f","\u11dd"],["\u3170","\u11df"],["\u3171","\u111d"],["\u3172","\u111e"],["\u3173","\u1120"],["\u3174","\u1122"],["\u3175","\u1123"],["\u3176","\u1127"],["\u3177","\u1129"],["\u3178","\u112b"],["\u3179","\u112c"],["\u317a","\u112d"],["\u317b","\u112e"],["\u317c","\u112f"],["\u317d","\u1132"],["\u317e","\u1136"],["\u317f","\u1140"],["\u3180","\u1147"],["\u3181","\u114c"],["\u3182","\u11f1"],["\u3183","\u11f2"],["\u3184","\u1157"],["\u3185","\u1158"],["\u3186","\u1159"],["\u3187","\u1184"],["\u3188","\u1185"],["\u3189","\u1188"],["\u318a","\u1191"],["\u318b","\u1192"],["\u318c","\u1194"],["\u318d","\u119e"],["\u318e","\u11a1"],["\u3192","\u4e00"],["\u3193","\u4e8c"],["\u3194","\u4e09"],["\u3195","\u56db"],["\u3196","\u4e0a"],["\u3197","\u4e2d"],["\u3198","\u4e0b"],["\u3199","\u7532"],["\u319a","\u4e59"],["\u319b","\u4e19"],["\u319c","\u4e01"],["\u319d","\u5929"],["\u319e","\u5730"],["\u319f","\u4eba"],["\u3200","(\u1100)"],["\u3201","(\u1102)"],["\u3202","(\u1103)"],["\u3203","(\u1105)"],["\u3204","(\u1106)"],["\u3205","(\u1107)"],["\u3206","(\u1109)"],["\u3207","(\u110b)"],["\u3208","(\u110c)"],["\u3209","(\u110e)"],["\u320a","(\u110f)"],["\u320b","(\u1110)"],["\u320c","(\u1111)"],["\u320d","(\u1112)"],["\u320e","(\u1100\u1161)"],["\u320f","(\u1102\u1161)"],["\u3210","(\u1103\u1161)"],["\u3211","(\u1105\u1161)"],["\u3212","(\u1106\u1161)"],["\u3213","(\u1107\u1161)"],["\u3214","(\u1109\u1161)"],["\u3215","(\u110b\u1161)"],["\u3216","(\u110c\u1161)"],["\u3217","(\u110e\u1161)"],["\u3218","(\u110f\u1161)"],["\u3219","(\u1110\u1161)"],["\u321a","(\u1111\u1161)"],["\u321b","(\u1112\u1161)"],["\u321c","(\u110c\u116e)"],["\u321d","(\u110b\u1169\u110c\u1165\u11ab)"],["\u321e","(\u110b\u1169\u1112\u116e)"],["\u3220","(\u4e00)"],["\u3221","(\u4e8c)"],["\u3222","(\u4e09)"],["\u3223","(\u56db)"],["\u3224","(\u4e94)"],["\u3225","(\u516d)"],["\u3226","(\u4e03)"],["\u3227","(\u516b)"],["\u3228","(\u4e5d)"],["\u3229","(\u5341)"],["\u322a","(\u6708)"],["\u322b","(\u706b)"],["\u322c","(\u6c34)"],["\u322d","(\u6728)"],["\u322e","(\u91d1)"],["\u322f","(\u571f)"],["\u3230","(\u65e5)"],["\u3231","(\u682a)"],["\u3232","(\u6709)"],["\u3233","(\u793e)"],["\u3234","(\u540d)"],["\u3235","(\u7279)"],["\u3236","(\u8ca1)"],["\u3237","(\u795d)"],["\u3238","(\u52b4)"],["\u3239","(\u4ee3)"],["\u323a","(\u547c)"],["\u323b","(\u5b66)"],["\u323c","(\u76e3)"],["\u323d","(\u4f01)"],["\u323e","(\u8cc7)"],["\u323f","(\u5354)"],["\u3240","(\u796d)"],["\u3241","(\u4f11)"],["\u3242","(\u81ea)"],["\u3243","(\u81f3)"],["\u3244","\u554f"],["\u3245","\u5e7c"],["\u3246","\u6587"],["\u3247","\u7b8f"],["\u3250","pte"],["\u3251","21"],["\u3252","22"],["\u3253","23"],["\u3254","24"],["\u3255","25"],["\u3256","26"],["\u3257","27"],["\u3258","28"],["\u3259","29"],["\u325a","30"],["\u325b","31"],["\u325c","32"],["\u325d","33"],["\u325e","34"],["\u325f","35"],["\u3260","\u1100"],["\u3261","\u1102"],["\u3262","\u1103"],["\u3263","\u1105"],["\u3264","\u1106"],["\u3265","\u1107"],["\u3266","\u1109"],["\u3267","\u110b"],["\u3268","\u110c"],["\u3269","\u110e"],["\u326a","\u110f"],["\u326b","\u1110"],["\u326c","\u1111"],["\u326d","\u1112"],["\u326e","\u1100\u1161"],["\u326f","\u1102\u1161"],["\u3270","\u1103\u1161"],["\u3271","\u1105\u1161"],["\u3272","\u1106\u1161"],["\u3273","\u1107\u1161"],["\u3274","\u1109\u1161"],["\u3275","\u110b\u1161"],["\u3276","\u110c\u1161"],["\u3277","\u110e\u1161"],["\u3278","\u110f\u1161"],["\u3279","\u1110\u1161"],["\u327a","\u1111\u1161"],["\u327b","\u1112\u1161"],["\u327c","\u110e\u1161\u11b7\u1100\u1169"],["\u327d","\u110c\u116e\u110b\u1174"],["\u327e","\u110b\u116e"],["\u3280","\u4e00"],["\u3281","\u4e8c"],["\u3282","\u4e09"],["\u3283","\u56db"],["\u3284","\u4e94"],["\u3285","\u516d"],["\u3286","\u4e03"],["\u3287","\u516b"],["\u3288","\u4e5d"],["\u3289","\u5341"],["\u328a","\u6708"],["\u328b","\u706b"],["\u328c","\u6c34"],["\u328d","\u6728"],["\u328e","\u91d1"],["\u328f","\u571f"],["\u3290","\u65e5"],["\u3291","\u682a"],["\u3292","\u6709"],["\u3293","\u793e"],["\u3294","\u540d"],["\u3295","\u7279"],["\u3296","\u8ca1"],["\u3297","\u795d"],["\u3298","\u52b4"],["\u3299","\u79d8"],["\u329a","\u7537"],["\u329b","\u5973"],["\u329c","\u9069"],["\u329d","\u512a"],["\u329e","\u5370"],["\u329f","\u6ce8"],["\u32a0","\u9805"],["\u32a1","\u4f11"],["\u32a2","\u5199"],["\u32a3","\u6b63"],["\u32a4","\u4e0a"],["\u32a5","\u4e2d"],["\u32a6","\u4e0b"],["\u32a7","\u5de6"],["\u32a8","\u53f3"],["\u32a9","\u533b"],["\u32aa","\u5b97"],["\u32ab","\u5b66"],["\u32ac","\u76e3"],["\u32ad","\u4f01"],["\u32ae","\u8cc7"],["\u32af","\u5354"],["\u32b0","\u591c"],["\u32b1","36"],["\u32b2","37"],["\u32b3","38"],["\u32b4","39"],["\u32b5","40"],["\u32b6","41"],["\u32b7","42"],["\u32b8","43"],["\u32b9","44"],["\u32ba","45"],["\u32bb","46"],["\u32bc","47"],["\u32bd","48"],["\u32be","49"],["\u32bf","50"],["\u32c0","1\u6708"],["\u32c1","2\u6708"],["\u32c2","3\u6708"],["\u32c3","4\u6708"],["\u32c4","5\u6708"],["\u32c5","6\u6708"],["\u32c6","7\u6708"],["\u32c7","8\u6708"],["\u32c8","9\u6708"],["\u32c9","10\u6708"],["\u32ca","11\u6708"],["\u32cb","12\u6708"],["\u32cc","hg"],["\u32cd","erg"],["\u32ce","ev"],["\u32cf","ltd"],["\u32d0","\u30a2"],["\u32d1","\u30a4"],["\u32d2","\u30a6"],["\u32d3","\u30a8"],["\u32d4","\u30aa"],["\u32d5","\u30ab"],["\u32d6","\u30ad"],["\u32d7","\u30af"],["\u32d8","\u30b1"],["\u32d9","\u30b3"],["\u32da","\u30b5"],["\u32db","\u30b7"],["\u32dc","\u30b9"],["\u32dd","\u30bb"],["\u32de","\u30bd"],["\u32df","\u30bf"],["\u32e0","\u30c1"],["\u32e1","\u30c4"],["\u32e2","\u30c6"],["\u32e3","\u30c8"],["\u32e4","\u30ca"],["\u32e5","\u30cb"],["\u32e6","\u30cc"],["\u32e7","\u30cd"],["\u32e8","\u30ce"],["\u32e9","\u30cf"],["\u32ea","\u30d2"],["\u32eb","\u30d5"],["\u32ec","\u30d8"],["\u32ed","\u30db"],["\u32ee","\u30de"],["\u32ef","\u30df"],["\u32f0","\u30e0"],["\u32f1","\u30e1"],["\u32f2","\u30e2"],["\u32f3","\u30e4"],["\u32f4","\u30e6"],["\u32f5","\u30e8"],["\u32f6","\u30e9"],["\u32f7","\u30ea"],["\u32f8","\u30eb"],["\u32f9","\u30ec"],["\u32fa","\u30ed"],["\u32fb","\u30ef"],["\u32fc","\u30f0"],["\u32fd","\u30f1"],["\u32fe","\u30f2"],["\u32ff","\u4ee4\u548c"],["\u3300","\u30a2\u30cf\u30fc\u30c8"],["\u3301","\u30a2\u30eb\u30d5\u30a1"],["\u3302","\u30a2\u30f3\u30d8\u30a2"],["\u3303","\u30a2\u30fc\u30eb"],["\u3304","\u30a4\u30cb\u30f3\u30af"],["\u3305","\u30a4\u30f3\u30c1"],["\u3306","\u30a6\u30a9\u30f3"],["\u3307","\u30a8\u30b9\u30af\u30fc\u30c8"],["\u3308","\u30a8\u30fc\u30ab\u30fc"],["\u3309","\u30aa\u30f3\u30b9"],["\u330a","\u30aa\u30fc\u30e0"],["\u330b","\u30ab\u30a4\u30ea"],["\u330c","\u30ab\u30e9\u30c3\u30c8"],["\u330d","\u30ab\u30ed\u30ea\u30fc"],["\u330e","\u30ab\u30ed\u30f3"],["\u330f","\u30ab\u30f3\u30de"],["\u3310","\u30ad\u30ab"],["\u3311","\u30ad\u30cb\u30fc"],["\u3312","\u30ad\u30e5\u30ea\u30fc"],["\u3313","\u30ad\u30eb\u30bf\u30fc"],["\u3314","\u30ad\u30ed"],["\u3315","\u30ad\u30ed\u30af\u30e9\u30e0"],["\u3316","\u30ad\u30ed\u30e1\u30fc\u30c8\u30eb"],["\u3317","\u30ad\u30ed\u30ef\u30c3\u30c8"],["\u3318","\u30af\u30e9\u30e0"],["\u3319","\u30af\u30e9\u30e0\u30c8\u30f3"],["\u331a","\u30af\u30eb\u30bb\u30a4\u30ed"],["\u331b","\u30af\u30ed\u30fc\u30cd"],["\u331c","\u30b1\u30fc\u30b9"],["\u331d","\u30b3\u30eb\u30ca"],["\u331e","\u30b3\u30fc\u30db"],["\u331f","\u30b5\u30a4\u30af\u30eb"],["\u3320","\u30b5\u30f3\u30c1\u30fc\u30e0"],["\u3321","\u30b7\u30ea\u30f3\u30af"],["\u3322","\u30bb\u30f3\u30c1"],["\u3323","\u30bb\u30f3\u30c8"],["\u3324","\u30bf\u30fc\u30b9"],["\u3325","\u30c6\u30b7"],["\u3326","\u30c8\u30eb"],["\u3327","\u30c8\u30f3"],["\u3328","\u30ca\u30ce"],["\u3329","\u30ce\u30c3\u30c8"],["\u332a","\u30cf\u30a4\u30c4"],["\u332b","\u30cf\u30fc\u30bb\u30f3\u30c8"],["\u332c","\u30cf\u30fc\u30c4"],["\u332d","\u30cf\u30fc\u30ec\u30eb"],["\u332e","\u30d2\u30a2\u30b9\u30c8\u30eb"],["\u332f","\u30d2\u30af\u30eb"],["\u3330","\u30d2\u30b3"],["\u3331","\u30d2\u30eb"],["\u3332","\u30d5\u30a1\u30e9\u30c3\u30c8"],["\u3333","\u30d5\u30a3\u30fc\u30c8"],["\u3334","\u30d5\u30c3\u30b7\u30a7\u30eb"],["\u3335","\u30d5\u30e9\u30f3"],["\u3336","\u30d8\u30af\u30bf\u30fc\u30eb"],["\u3337","\u30d8\u30bd"],["\u3338","\u30d8\u30cb\u30d2"],["\u3339","\u30d8\u30eb\u30c4"],["\u333a","\u30d8\u30f3\u30b9"],["\u333b","\u30d8\u30fc\u30b7"],["\u333c","\u30d8\u30fc\u30bf"],["\u333d","\u30db\u30a4\u30f3\u30c8"],["\u333e","\u30db\u30eb\u30c8"],["\u333f","\u30db\u30f3"],["\u3340","\u30db\u30f3\u30c8"],["\u3341","\u30db\u30fc\u30eb"],["\u3342","\u30db\u30fc\u30f3"],["\u3343","\u30de\u30a4\u30af\u30ed"],["\u3344","\u30de\u30a4\u30eb"],["\u3345","\u30de\u30c3\u30cf"],["\u3346","\u30de\u30eb\u30af"],["\u3347","\u30de\u30f3\u30b7\u30e7\u30f3"],["\u3348","\u30df\u30af\u30ed\u30f3"],["\u3349","\u30df\u30ea"],["\u334a","\u30df\u30ea\u30cf\u30fc\u30eb"],["\u334b","\u30e1\u30ab"],["\u334c","\u30e1\u30ab\u30c8\u30f3"],["\u334d","\u30e1\u30fc\u30c8\u30eb"],["\u334e","\u30e4\u30fc\u30c8"],["\u334f","\u30e4\u30fc\u30eb"],["\u3350","\u30e6\u30a2\u30f3"],["\u3351","\u30ea\u30c3\u30c8\u30eb"],["\u3352","\u30ea\u30e9"],["\u3353","\u30eb\u30d2\u30fc"],["\u3354","\u30eb\u30fc\u30d5\u30eb"],["\u3355","\u30ec\u30e0"],["\u3356","\u30ec\u30f3\u30c8\u30b1\u30f3"],["\u3357","\u30ef\u30c3\u30c8"],["\u3358","0\u70b9"],["\u3359","1\u70b9"],["\u335a","2\u70b9"],["\u335b","3\u70b9"],["\u335c","4\u70b9"],["\u335d","5\u70b9"],["\u335e","6\u70b9"],["\u335f","7\u70b9"],["\u3360","8\u70b9"],["\u3361","9\u70b9"],["\u3362","10\u70b9"],["\u3363","11\u70b9"],["\u3364","12\u70b9"],["\u3365","13\u70b9"],["\u3366","14\u70b9"],["\u3367","15\u70b9"],["\u3368","16\u70b9"],["\u3369","17\u70b9"],["\u336a","18\u70b9"],["\u336b","19\u70b9"],["\u336c","20\u70b9"],["\u336d","21\u70b9"],["\u336e","22\u70b9"],["\u336f","23\u70b9"],["\u3370","24\u70b9"],["\u3371","hpa"],["\u3372","da"],["\u3373","au"],["\u3374","bar"],["\u3375","ov"],["\u3376","pc"],["\u3377","dm"],["\u3378","dm2"],["\u3379","dm3"],["\u337a","iu"],["\u337b","\u5e73\u6210"],["\u337c","\u662d\u548c"],["\u337d","\u5927\u6b63"],["\u337e","\u660e\u6cbb"],["\u337f","\u682a\u5f0f\u4f1a\u793e"],["\u3380","pa"],["\u3381","na"],["\u3382","\u03bca"],["\u3383","ma"],["\u3384","ka"],["\u3385","kb"],["\u3386","mb"],["\u3387","gb"],["\u3388","cal"],["\u3389","kcal"],["\u338a","pf"],["\u338b","nf"],["\u338c","\u03bcf"],["\u338d","\u03bcg"],["\u338e","mg"],["\u338f","kg"],["\u3390","hz"],["\u3391","khz"],["\u3392","mhz"],["\u3393","ghz"],["\u3394","thz"],["\u3395","\u03bcl"],["\u3396","ml"],["\u3397","dl"],["\u3398","kl"],["\u3399","fm"],["\u339a","nm"],["\u339b","\u03bcm"],["\u339c","mm"],["\u339d","cm"],["\u339e","km"],["\u339f","mm2"],["\u33a0","cm2"],["\u33a1","m2"],["\u33a2","km2"],["\u33a3","mm3"],["\u33a4","cm3"],["\u33a5","m3"],["\u33a6","km3"],["\u33a7","m\u2215s"],["\u33a8","m\u2215s2"],["\u33a9","pa"],["\u33aa","kpa"],["\u33ab","mpa"],["\u33ac","gpa"],["\u33ad","rad"],["\u33ae","rad\u2215s"],["\u33af","rad\u2215s2"],["\u33b0","ps"],["\u33b1","ns"],["\u33b2","\u03bcs"],["\u33b3","ms"],["\u33b4","pv"],["\u33b5","nv"],["\u33b6","\u03bcv"],["\u33b7","mv"],["\u33b8","kv"],["\u33b9","mv"],["\u33ba","pw"],["\u33bb","nw"],["\u33bc","\u03bcw"],["\u33bd","mw"],["\u33be","kw"],["\u33bf","mw"],["\u33c0","k\u03c9"],["\u33c1","m\u03c9"],["\u33c2","a.m."],["\u33c3","bq"],["\u33c4","cc"],["\u33c5","cd"],["\u33c6","c\u2215kg"],["\u33c7","co."],["\u33c8","db"],["\u33c9","gy"],["\u33ca","ha"],["\u33cb","hp"],["\u33cc","in"],["\u33cd","kk"],["\u33ce","km"],["\u33cf","kt"],["\u33d0","lm"],["\u33d1","ln"],["\u33d2","log"],["\u33d3","lx"],["\u33d4","mb"],["\u33d5","mil"],["\u33d6","mol"],["\u33d7","ph"],["\u33d8","p.m."],["\u33d9","ppm"],["\u33da","pr"],["\u33db","sr"],["\u33dc","sv"],["\u33dd","wb"],["\u33de","v\u2215m"],["\u33df","a\u2215m"],["\u33e0","1\u65e5"],["\u33e1","2\u65e5"],["\u33e2","3\u65e5"],["\u33e3","4\u65e5"],["\u33e4","5\u65e5"],["\u33e5","6\u65e5"],["\u33e6","7\u65e5"],["\u33e7","8\u65e5"],["\u33e8","9\u65e5"],["\u33e9","10\u65e5"],["\u33ea","11\u65e5"],["\u33eb","12\u65e5"],["\u33ec","13\u65e5"],["\u33ed","14\u65e5"],["\u33ee","15\u65e5"],["\u33ef","16\u65e5"],["\u33f0","17\u65e5"],["\u33f1","18\u65e5"],["\u33f2","19\u65e5"],["\u33f3","20\u65e5"],["\u33f4","21\u65e5"],["\u33f5","22\u65e5"],["\u33f6","23\u65e5"],["\u33f7","24\u65e5"],["\u33f8","25\u65e5"],["\u33f9","26\u65e5"],["\u33fa","27\u65e5"],["\u33fb","28\u65e5"],["\u33fc","29\u65e5"],["\u33fd","30\u65e5"],["\u33fe","31\u65e5"],["\u33ff","gal"],["\ua640","\ua641"],["\ua642","\ua643"],["\ua644","\ua645"],["\ua646","\ua647"],["\ua648","\ua649"],["\ua64a","\ua64b"],["\ua64c","\ua64d"],["\ua64e","\ua64f"],["\ua650","\ua651"],["\ua652","\ua653"],["\ua654","\ua655"],["\ua656","\ua657"],["\ua658","\ua659"],["\ua65a","\ua65b"],["\ua65c","\ua65d"],["\ua65e","\ua65f"],["\ua660","\ua661"],["\ua662","\ua663"],["\ua664","\ua665"],["\ua666","\ua667"],["\ua668","\ua669"],["\ua66a","\ua66b"],["\ua66c","\ua66d"],["\ua66f",""],["\ua674",""],["\ua675",""],["\ua676",""],["\ua677",""],["\ua678",""],["\ua679",""],["\ua67a",""],["\ua67b",""],["\ua67c",""],["\ua67d",""],["\ua680","\ua681"],["\ua682","\ua683"],["\ua684","\ua685"],["\ua686","\ua687"],["\ua688","\ua689"],["\ua68a","\ua68b"],["\ua68c","\ua68d"],["\ua68e","\ua68f"],["\ua690","\ua691"],["\ua692","\ua693"],["\ua694","\ua695"],["\ua696","\ua697"],["\ua698","\ua699"],["\ua69a","\ua69b"],["\ua69c","\u044a"],["\ua69d","\u044c"],["\ua69e",""],["\ua69f",""],["\ua6f0",""],["\ua6f1",""],["\ua722","\ua723"],["\ua724","\ua725"],["\ua726","\ua727"],["\ua728","\ua729"],["\ua72a","\ua72b"],["\ua72c","\ua72d"],["\ua72e","\ua72f"],["\ua732","\ua733"],["\ua734","\ua735"],["\ua736","\ua737"],["\ua738","\ua739"],["\ua73a","\ua73b"],["\ua73c","\ua73d"],["\ua73e","\ua73f"],["\ua740","\ua741"],["\ua742","\ua743"],["\ua744","\ua745"],["\ua746","\ua747"],["\ua748","\ua749"],["\ua74a","\ua74b"],["\ua74c","\ua74d"],["\ua74e","\ua74f"],["\ua750","\ua751"],["\ua752","\ua753"],["\ua754","\ua755"],["\ua756","\ua757"],["\ua758","\ua759"],["\ua75a","\ua75b"],["\ua75c","\ua75d"],["\ua75e","\ua75f"],["\ua760","\ua761"],["\ua762","\ua763"],["\ua764","\ua765"],["\ua766","\ua767"],["\ua768","\ua769"],["\ua76a","\ua76b"],["\ua76c","\ua76d"],["\ua76e","\ua76f"],["\ua770","\ua76f"],["\ua779","\ua77a"],["\ua77b","\ua77c"],["\ua77d","\u1d79"],["\ua77e","\ua77f"],["\ua780","\ua781"],["\ua782","\ua783"],["\ua784","\ua785"],["\ua786","\ua787"],["\ua78b","\ua78c"],["\ua78d","\u0265"],["\ua790","\ua791"],["\ua792","\ua793"],["\ua796","\ua797"],["\ua798","\ua799"],["\ua79a","\ua79b"],["\ua79c","\ua79d"],["\ua79e","\ua79f"],["\ua7a0","\ua7a1"],["\ua7a2","\ua7a3"],["\ua7a4","\ua7a5"],["\ua7a6","\ua7a7"],["\ua7a8","\ua7a9"],["\ua7aa","\u0266"],["\ua7ab","\u025c"],["\ua7ac","\u0261"],["\ua7ad","\u026c"],["\ua7ae","\u026a"],["\ua7b0","\u029e"],["\ua7b1","\u0287"],["\ua7b2","\u029d"],["\ua7b3","\uab53"],["\ua7b4","\ua7b5"],["\ua7b6","\ua7b7"],["\ua7b8","\ua7b9"],["\ua7ba","\ua7bb"],["\ua7bc","\ua7bd"],["\ua7be","\ua7bf"],["\ua7c0","\ua7c1"],["\ua7c2","\ua7c3"],["\ua7c4","\ua794"],["\ua7c5","\u0282"],["\ua7c6","\u1d8e"],["\ua7c7","\ua7c8"],["\ua7c9","\ua7ca"],["\ua7cb","\u0264"],["\ua7cc","\ua7cd"],["\ua7d0","\ua7d1"],["\ua7d6","\ua7d7"],["\ua7d8","\ua7d9"],["\ua7da","\ua7db"],["\ua7dc","\u019b"],["\ua7f2","c"],["\ua7f3","f"],["\ua7f4","q"],["\ua7f5","\ua7f6"],["\ua7f8","\u0127"],["\ua7f9","\u0153"],["\ua806",""],["\ua82c",""],["\ua8c4",""],["\ua8e0",""],["\ua8e1",""],["\ua8e2",""],["\ua8e3",""],["\ua8e4",""],["\ua8e5",""],["\ua8e6",""],["\ua8e7",""],["\ua8e8",""],["\ua8e9",""],["\ua8ea",""],["\ua8eb",""],["\ua8ec",""],["\ua8ed",""],["\ua8ee",""],["\ua8ef",""],["\ua8f0",""],["\ua8f1",""],["\ua92b",""],["\ua92c",""],["\ua92d",""],["\ua953",""],["\ua9b3",""],["\ua9c0",""],["\uaab0",""],["\uaab2",""],["\uaab3",""],["\uaab4",""],["\uaab7",""],["\uaab8",""],["\uaabe",""],["\uaabf",""],["\uaac1",""],["\uaaf6",""],["\uab5c","\ua727"],["\uab5d","\uab37"],["\uab5e","\u026b"],["\uab5f","\uab52"],["\uab69","\u028d"],["\uab70","\u13a0"],["\uab71","\u13a1"],["\uab72","\u13a2"],["\uab73","\u13a3"],["\uab74","\u13a4"],["\uab75","\u13a5"],["\uab76","\u13a6"],["\uab77","\u13a7"],["\uab78","\u13a8"],["\uab79","\u13a9"],["\uab7a","\u13aa"],["\uab7b","\u13ab"],["\uab7c","\u13ac"],["\uab7d","\u13ad"],["\uab7e","\u13ae"],["\uab7f","\u13af"],["\uab80","\u13b0"],["\uab81","\u13b1"],["\uab82","\u13b2"],["\uab83","\u13b3"],["\uab84","\u13b4"],["\uab85","\u13b5"],["\uab86","\u13b6"],["\uab87","\u13b7"],["\uab88","\u13b8"],["\uab89","\u13b9"],["\uab8a","\u13ba"],["\uab8b","\u13bb"],["\uab8c","\u13bc"],["\uab8d","\u13bd"],["\uab8e","\u13be"],["\uab8f","\u13bf"],["\uab90","\u13c0"],["\uab91","\u13c1"],["\uab92","\u13c2"],["\uab93","\u13c3"],["\uab94","\u13c4"],["\uab95","\u13c5"],["\uab96","\u13c6"],["\uab97","\u13c7"],["\uab98","\u13c8"],["\uab99","\u13c9"],["\uab9a","\u13ca"],["\uab9b","\u13cb"],["\uab9c","\u13cc"],["\uab9d","\u13cd"],["\uab9e","\u13ce"],["\uab9f","\u13cf"],["\uaba0","\u13d0"],["\uaba1","\u13d1"],["\uaba2","\u13d2"],["\uaba3","\u13d3"],["\uaba4","\u13d4"],["\uaba5","\u13d5"],["\uaba6","\u13d6"],["\uaba7","\u13d7"],["\uaba8","\u13d8"],["\uaba9","\u13d9"],["\uabaa","\u13da"],["\uabab","\u13db"],["\uabac","\u13dc"],["\uabad","\u13dd"],["\uabae","\u13de"],["\uabaf","\u13df"],["\uabb0","\u13e0"],["\uabb1","\u13e1"],["\uabb2","\u13e2"],["\uabb3","\u13e3"],["\uabb4","\u13e4"],["\uabb5","\u13e5"],["\uabb6","\u13e6"],["\uabb7","\u13e7"],["\uabb8","\u13e8"],["\uabb9","\u13e9"],["\uabba","\u13ea"],["\uabbb","\u13eb"],["\uabbc","\u13ec"],["\uabbd","\u13ed"],["\uabbe","\u13ee"],["\uabbf","\u13ef"],["\uabed",""],["\uac00","\u1100\u1161"],["\uac01","\u1100\u1161\u11a8"],["\uac02","\u1100\u1161\u11a9"],["\uac03","\u1100\u1161\u11aa"],["\uac04","\u1100\u1161\u11ab"],["\uac05","\u1100\u1161\u11ac"],["\uac06","\u1100\u1161\u11ad"],["\uac07","\u1100\u1161\u11ae"],["\uac08","\u1100\u1161\u11af"],["\uac09","\u1100\u1161\u11b0"],["\uac0a","\u1100\u1161\u11b1"],["\uac0b","\u1100\u1161\u11b2"],["\uac0c","\u1100\u1161\u11b3"],["\uac0d","\u1100\u1161\u11b4"],["\uac0e","\u1100\u1161\u11b5"],["\uac0f","\u1100\u1161\u11b6"],["\uac10","\u1100\u1161\u11b7"],["\uac11","\u1100\u1161\u11b8"],["\uac12","\u1100\u1161\u11b9"],["\uac13","\u1100\u1161\u11ba"],["\uac14","\u1100\u1161\u11bb"],["\uac15","\u1100\u1161\u11bc"],["\uac16","\u1100\u1161\u11bd"],["\uac17","\u1100\u1161\u11be"],["\uac18","\u1100\u1161\u11bf"],["\uac19","\u1100\u1161\u11c0"],["\uac1a","\u1100\u1161\u11c1"],["\uac1b","\u1100\u1161\u11c2"],["\uac1c","\u1100\u1162"],["\uac1d","\u1100\u1162\u11a8"],["\uac1e","\u1100\u1162\u11a9"],["\uac1f","\u1100\u1162\u11aa"],["\uac20","\u1100\u1162\u11ab"],["\uac21","\u1100\u1162\u11ac"],["\uac22","\u1100\u1162\u11ad"],["\uac23","\u1100\u1162\u11ae"],["\uac24","\u1100\u1162\u11af"],["\uac25","\u1100\u1162\u11b0"],["\uac26","\u1100\u1162\u11b1"],["\uac27","\u1100\u1162\u11b2"],["\uac28","\u1100\u1162\u11b3"],["\uac29","\u1100\u1162\u11b4"],["\uac2a","\u1100\u1162\u11b5"],["\uac2b","\u1100\u1162\u11b6"],["\uac2c","\u1100\u1162\u11b7"],["\uac2d","\u1100\u1162\u11b8"],["\uac2e","\u1100\u1162\u11b9"],["\uac2f","\u1100\u1162\u11ba"],["\uac30","\u1100\u1162\u11bb"],["\uac31","\u1100\u1162\u11bc"],["\uac32","\u1100\u1162\u11bd"],["\uac33","\u1100\u1162\u11be"],["\uac34","\u1100\u1162\u11bf"],["\uac35","\u1100\u1162\u11c0"],["\uac36","\u1100\u1162\u11c1"],["\uac37","\u1100\u1162\u11c2"],["\uac38","\u1100\u1163"],["\uac39","\u1100\u1163\u11a8"],["\uac3a","\u1100\u1163\u11a9"],["\uac3b","\u1100\u1163\u11aa"],["\uac3c","\u1100\u1163\u11ab"],["\uac3d","\u1100\u1163\u11ac"],["\uac3e","\u1100\u1163\u11ad"],["\uac3f","\u1100\u1163\u11ae"],["\uac40","\u1100\u1163\u11af"],["\uac41","\u1100\u1163\u11b0"],["\uac42","\u1100\u1163\u11b1"],["\uac43","\u1100\u1163\u11b2"],["\uac44","\u1100\u1163\u11b3"],["\uac45","\u1100\u1163\u11b4"],["\uac46","\u1100\u1163\u11b5"],["\uac47","\u1100\u1163\u11b6"],["\uac48","\u1100\u1163\u11b7"],["\uac49","\u1100\u1163\u11b8"],["\uac4a","\u1100\u1163\u11b9"],["\uac4b","\u1100\u1163\u11ba"],["\uac4c","\u1100\u1163\u11bb"],["\uac4d","\u1100\u1163\u11bc"],["\uac4e","\u1100\u1163\u11bd"],["\uac4f","\u1100\u1163\u11be"],["\uac50","\u1100\u1163\u11bf"],["\uac51","\u1100\u1163\u11c0"],["\uac52","\u1100\u1163\u11c1"],["\uac53","\u1100\u1163\u11c2"],["\uac54","\u1100\u1164"],["\uac55","\u1100\u1164\u11a8"],["\uac56","\u1100\u1164\u11a9"],["\uac57","\u1100\u1164\u11aa"],["\uac58","\u1100\u1164\u11ab"],["\uac59","\u1100\u1164\u11ac"],["\uac5a","\u1100\u1164\u11ad"],["\uac5b","\u1100\u1164\u11ae"],["\uac5c","\u1100\u1164\u11af"],["\uac5d","\u1100\u1164\u11b0"],["\uac5e","\u1100\u1164\u11b1"],["\uac5f","\u1100\u1164\u11b2"],["\uac60","\u1100\u1164\u11b3"],["\uac61","\u1100\u1164\u11b4"],["\uac62","\u1100\u1164\u11b5"],["\uac63","\u1100\u1164\u11b6"],["\uac64","\u1100\u1164\u11b7"],["\uac65","\u1100\u1164\u11b8"],["\uac66","\u1100\u1164\u11b9"],["\uac67","\u1100\u1164\u11ba"],["\uac68","\u1100\u1164\u11bb"],["\uac69","\u1100\u1164\u11bc"],["\uac6a","\u1100\u1164\u11bd"],["\uac6b","\u1100\u1164\u11be"],["\uac6c","\u1100\u1164\u11bf"],["\uac6d","\u1100\u1164\u11c0"],["\uac6e","\u1100\u1164\u11c1"],["\uac6f","\u1100\u1164\u11c2"],["\uac70","\u1100\u1165"],["\uac71","\u1100\u1165\u11a8"],["\uac72","\u1100\u1165\u11a9"],["\uac73","\u1100\u1165\u11aa"],["\uac74","\u1100\u1165\u11ab"],["\uac75","\u1100\u1165\u11ac"],["\uac76","\u1100\u1165\u11ad"],["\uac77","\u1100\u1165\u11ae"],["\uac78","\u1100\u1165\u11af"],["\uac79","\u1100\u1165\u11b0"],["\uac7a","\u1100\u1165\u11b1"],["\uac7b","\u1100\u1165\u11b2"],["\uac7c","\u1100\u1165\u11b3"],["\uac7d","\u1100\u1165\u11b4"],["\uac7e","\u1100\u1165\u11b5"],["\uac7f","\u1100\u1165\u11b6"],["\uac80","\u1100\u1165\u11b7"],["\uac81","\u1100\u1165\u11b8"],["\uac82","\u1100\u1165\u11b9"],["\uac83","\u1100\u1165\u11ba"],["\uac84","\u1100\u1165\u11bb"],["\uac85","\u1100\u1165\u11bc"],["\uac86","\u1100\u1165\u11bd"],["\uac87","\u1100\u1165\u11be"],["\uac88","\u1100\u1165\u11bf"],["\uac89","\u1100\u1165\u11c0"],["\uac8a","\u1100\u1165\u11c1"],["\uac8b","\u1100\u1165\u11c2"],["\uac8c","\u1100\u1166"],["\uac8d","\u1100\u1166\u11a8"],["\uac8e","\u1100\u1166\u11a9"],["\uac8f","\u1100\u1166\u11aa"],["\uac90","\u1100\u1166\u11ab"],["\uac91","\u1100\u1166\u11ac"],["\uac92","\u1100\u1166\u11ad"],["\uac93","\u1100\u1166\u11ae"],["\uac94","\u1100\u1166\u11af"],["\uac95","\u1100\u1166\u11b0"],["\uac96","\u1100\u1166\u11b1"],["\uac97","\u1100\u1166\u11b2"],["\uac98","\u1100\u1166\u11b3"],["\uac99","\u1100\u1166\u11b4"],["\uac9a","\u1100\u1166\u11b5"],["\uac9b","\u1100\u1166\u11b6"],["\uac9c","\u1100\u1166\u11b7"],["\uac9d","\u1100\u1166\u11b8"],["\uac9e","\u1100\u1166\u11b9"],["\uac9f","\u1100\u1166\u11ba"],["\uaca0","\u1100\u1166\u11bb"],["\uaca1","\u1100\u1166\u11bc"],["\uaca2","\u1100\u1166\u11bd"],["\uaca3","\u1100\u1166\u11be"],["\uaca4","\u1100\u1166\u11bf"],["\uaca5","\u1100\u1166\u11c0"],["\uaca6","\u1100\u1166\u11c1"],["\uaca7","\u1100\u1166\u11c2"],["\uaca8","\u1100\u1167"],["\uaca9","\u1100\u1167\u11a8"],["\uacaa","\u1100\u1167\u11a9"],["\uacab","\u1100\u1167\u11aa"],["\uacac","\u1100\u1167\u11ab"],["\uacad","\u1100\u1167\u11ac"],["\uacae","\u1100\u1167\u11ad"],["\uacaf","\u1100\u1167\u11ae"],["\uacb0","\u1100\u1167\u11af"],["\uacb1","\u1100\u1167\u11b0"],["\uacb2","\u1100\u1167\u11b1"],["\uacb3","\u1100\u1167\u11b2"],["\uacb4","\u1100\u1167\u11b3"],["\uacb5","\u1100\u1167\u11b4"],["\uacb6","\u1100\u1167\u11b5"],["\uacb7","\u1100\u1167\u11b6"],["\uacb8","\u1100\u1167\u11b7"],["\uacb9","\u1100\u1167\u11b8"],["\uacba","\u1100\u1167\u11b9"],["\uacbb","\u1100\u1167\u11ba"],["\uacbc","\u1100\u1167\u11bb"],["\uacbd","\u1100\u1167\u11bc"],["\uacbe","\u1100\u1167\u11bd"],["\uacbf","\u1100\u1167\u11be"],["\uacc0","\u1100\u1167\u11bf"],["\uacc1","\u1100\u1167\u11c0"],["\uacc2","\u1100\u1167\u11c1"],["\uacc3","\u1100\u1167\u11c2"],["\uacc4","\u1100\u1168"],["\uacc5","\u1100\u1168\u11a8"],["\uacc6","\u1100\u1168\u11a9"],["\uacc7","\u1100\u1168\u11aa"],["\uacc8","\u1100\u1168\u11ab"],["\uacc9","\u1100\u1168\u11ac"],["\uacca","\u1100\u1168\u11ad"],["\uaccb","\u1100\u1168\u11ae"],["\uaccc","\u1100\u1168\u11af"],["\uaccd","\u1100\u1168\u11b0"],["\uacce","\u1100\u1168\u11b1"],["\uaccf","\u1100\u1168\u11b2"],["\uacd0","\u1100\u1168\u11b3"],["\uacd1","\u1100\u1168\u11b4"],["\uacd2","\u1100\u1168\u11b5"],["\uacd3","\u1100\u1168\u11b6"],["\uacd4","\u1100\u1168\u11b7"],["\uacd5","\u1100\u1168\u11b8"],["\uacd6","\u1100\u1168\u11b9"],["\uacd7","\u1100\u1168\u11ba"],["\uacd8","\u1100\u1168\u11bb"],["\uacd9","\u1100\u1168\u11bc"],["\uacda","\u1100\u1168\u11bd"],["\uacdb","\u1100\u1168\u11be"],["\uacdc","\u1100\u1168\u11bf"],["\uacdd","\u1100\u1168\u11c0"],["\uacde","\u1100\u1168\u11c1"],["\uacdf","\u1100\u1168\u11c2"],["\uace0","\u1100\u1169"],["\uace1","\u1100\u1169\u11a8"],["\uace2","\u1100\u1169\u11a9"],["\uace3","\u1100\u1169\u11aa"],["\uace4","\u1100\u1169\u11ab"],["\uace5","\u1100\u1169\u11ac"],["\uace6","\u1100\u1169\u11ad"],["\uace7","\u1100\u1169\u11ae"],["\uace8","\u1100\u1169\u11af"],["\uace9","\u1100\u1169\u11b0"],["\uacea","\u1100\u1169\u11b1"],["\uaceb","\u1100\u1169\u11b2"],["\uacec","\u1100\u1169\u11b3"],["\uaced","\u1100\u1169\u11b4"],["\uacee","\u1100\u1169\u11b5"],["\uacef","\u1100\u1169\u11b6"],["\uacf0","\u1100\u1169\u11b7"],["\uacf1","\u1100\u1169\u11b8"],["\uacf2","\u1100\u1169\u11b9"],["\uacf3","\u1100\u1169\u11ba"],["\uacf4","\u1100\u1169\u11bb"],["\uacf5","\u1100\u1169\u11bc"],["\uacf6","\u1100\u1169\u11bd"],["\uacf7","\u1100\u1169\u11be"],["\uacf8","\u1100\u1169\u11bf"],["\uacf9","\u1100\u1169\u11c0"],["\uacfa","\u1100\u1169\u11c1"],["\uacfb","\u1100\u1169\u11c2"],["\uacfc","\u1100\u116a"],["\uacfd","\u1100\u116a\u11a8"],["\uacfe","\u1100\u116a\u11a9"],["\uacff","\u1100\u116a\u11aa"],["\uad00","\u1100\u116a\u11ab"],["\uad01","\u1100\u116a\u11ac"],["\uad02","\u1100\u116a\u11ad"],["\uad03","\u1100\u116a\u11ae"],["\uad04","\u1100\u116a\u11af"],["\uad05","\u1100\u116a\u11b0"],["\uad06","\u1100\u116a\u11b1"],["\uad07","\u1100\u116a\u11b2"],["\uad08","\u1100\u116a\u11b3"],["\uad09","\u1100\u116a\u11b4"],["\uad0a","\u1100\u116a\u11b5"],["\uad0b","\u1100\u116a\u11b6"],["\uad0c","\u1100\u116a\u11b7"],["\uad0d","\u1100\u116a\u11b8"],["\uad0e","\u1100\u116a\u11b9"],["\uad0f","\u1100\u116a\u11ba"],["\uad10","\u1100\u116a\u11bb"],["\uad11","\u1100\u116a\u11bc"],["\uad12","\u1100\u116a\u11bd"],["\uad13","\u1100\u116a\u11be"],["\uad14","\u1100\u116a\u11bf"],["\uad15","\u1100\u116a\u11c0"],["\uad16","\u1100\u116a\u11c1"],["\uad17","\u1100\u116a\u11c2"],["\uad18","\u1100\u116b"],["\uad19","\u1100\u116b\u11a8"],["\uad1a","\u1100\u116b\u11a9"],["\uad1b","\u1100\u116b\u11aa"],["\uad1c","\u1100\u116b\u11ab"],["\uad1d","\u1100\u116b\u11ac"],["\uad1e","\u1100\u116b\u11ad"],["\uad1f","\u1100\u116b\u11ae"],["\uad20","\u1100\u116b\u11af"],["\uad21","\u1100\u116b\u11b0"],["\uad22","\u1100\u116b\u11b1"],["\uad23","\u1100\u116b\u11b2"],["\uad24","\u1100\u116b\u11b3"],["\uad25","\u1100\u116b\u11b4"],["\uad26","\u1100\u116b\u11b5"],["\uad27","\u1100\u116b\u11b6"],["\uad28","\u1100\u116b\u11b7"],["\uad29","\u1100\u116b\u11b8"],["\uad2a","\u1100\u116b\u11b9"],["\uad2b","\u1100\u116b\u11ba"],["\uad2c","\u1100\u116b\u11bb"],["\uad2d","\u1100\u116b\u11bc"],["\uad2e","\u1100\u116b\u11bd"],["\uad2f","\u1100\u116b\u11be"],["\uad30","\u1100\u116b\u11bf"],["\uad31","\u1100\u116b\u11c0"],["\uad32","\u1100\u116b\u11c1"],["\uad33","\u1100\u116b\u11c2"],["\uad34","\u1100\u116c"],["\uad35","\u1100\u116c\u11a8"],["\uad36","\u1100\u116c\u11a9"],["\uad37","\u1100\u116c\u11aa"],["\uad38","\u1100\u116c\u11ab"],["\uad39","\u1100\u116c\u11ac"],["\uad3a","\u1100\u116c\u11ad"],["\uad3b","\u1100\u116c\u11ae"],["\uad3c","\u1100\u116c\u11af"],["\uad3d","\u1100\u116c\u11b0"],["\uad3e","\u1100\u116c\u11b1"],["\uad3f","\u1100\u116c\u11b2"],["\uad40","\u1100\u116c\u11b3"],["\uad41","\u1100\u116c\u11b4"],["\uad42","\u1100\u116c\u11b5"],["\uad43","\u1100\u116c\u11b6"],["\uad44","\u1100\u116c\u11b7"],["\uad45","\u1100\u116c\u11b8"],["\uad46","\u1100\u116c\u11b9"],["\uad47","\u1100\u116c\u11ba"],["\uad48","\u1100\u116c\u11bb"],["\uad49","\u1100\u116c\u11bc"],["\uad4a","\u1100\u116c\u11bd"],["\uad4b","\u1100\u116c\u11be"],["\uad4c","\u1100\u116c\u11bf"],["\uad4d","\u1100\u116c\u11c0"],["\uad4e","\u1100\u116c\u11c1"],["\uad4f","\u1100\u116c\u11c2"],["\uad50","\u1100\u116d"],["\uad51","\u1100\u116d\u11a8"],["\uad52","\u1100\u116d\u11a9"],["\uad53","\u1100\u116d\u11aa"],["\uad54","\u1100\u116d\u11ab"],["\uad55","\u1100\u116d\u11ac"],["\uad56","\u1100\u116d\u11ad"],["\uad57","\u1100\u116d\u11ae"],["\uad58","\u1100\u116d\u11af"],["\uad59","\u1100\u116d\u11b0"],["\uad5a","\u1100\u116d\u11b1"],["\uad5b","\u1100\u116d\u11b2"],["\uad5c","\u1100\u116d\u11b3"],["\uad5d","\u1100\u116d\u11b4"],["\uad5e","\u1100\u116d\u11b5"],["\uad5f","\u1100\u116d\u11b6"],["\uad60","\u1100\u116d\u11b7"],["\uad61","\u1100\u116d\u11b8"],["\uad62","\u1100\u116d\u11b9"],["\uad63","\u1100\u116d\u11ba"],["\uad64","\u1100\u116d\u11bb"],["\uad65","\u1100\u116d\u11bc"],["\uad66","\u1100\u116d\u11bd"],["\uad67","\u1100\u116d\u11be"],["\uad68","\u1100\u116d\u11bf"],["\uad69","\u1100\u116d\u11c0"],["\uad6a","\u1100\u116d\u11c1"],["\uad6b","\u1100\u116d\u11c2"],["\uad6c","\u1100\u116e"],["\uad6d","\u1100\u116e\u11a8"],["\uad6e","\u1100\u116e\u11a9"],["\uad6f","\u1100\u116e\u11aa"],["\uad70","\u1100\u116e\u11ab"],["\uad71","\u1100\u116e\u11ac"],["\uad72","\u1100\u116e\u11ad"],["\uad73","\u1100\u116e\u11ae"],["\uad74","\u1100\u116e\u11af"],["\uad75","\u1100\u116e\u11b0"],["\uad76","\u1100\u116e\u11b1"],["\uad77","\u1100\u116e\u11b2"],["\uad78","\u1100\u116e\u11b3"],["\uad79","\u1100\u116e\u11b4"],["\uad7a","\u1100\u116e\u11b5"],["\uad7b","\u1100\u116e\u11b6"],["\uad7c","\u1100\u116e\u11b7"],["\uad7d","\u1100\u116e\u11b8"],["\uad7e","\u1100\u116e\u11b9"],["\uad7f","\u1100\u116e\u11ba"],["\uad80","\u1100\u116e\u11bb"],["\uad81","\u1100\u116e\u11bc"],["\uad82","\u1100\u116e\u11bd"],["\uad83","\u1100\u116e\u11be"],["\uad84","\u1100\u116e\u11bf"],["\uad85","\u1100\u116e\u11c0"],["\uad86","\u1100\u116e\u11c1"],["\uad87","\u1100\u116e\u11c2"],["\uad88","\u1100\u116f"],["\uad89","\u1100\u116f\u11a8"],["\uad8a","\u1100\u116f\u11a9"],["\uad8b","\u1100\u116f\u11aa"],["\uad8c","\u1100\u116f\u11ab"],["\uad8d","\u1100\u116f\u11ac"],["\uad8e","\u1100\u116f\u11ad"],["\uad8f","\u1100\u116f\u11ae"],["\uad90","\u1100\u116f\u11af"],["\uad91","\u1100\u116f\u11b0"],["\uad92","\u1100\u116f\u11b1"],["\uad93","\u1100\u116f\u11b2"],["\uad94","\u1100\u116f\u11b3"],["\uad95","\u1100\u116f\u11b4"],["\uad96","\u1100\u116f\u11b5"],["\uad97","\u1100\u116f\u11b6"],["\uad98","\u1100\u116f\u11b7"],["\uad99","\u1100\u116f\u11b8"],["\uad9a","\u1100\u116f\u11b9"],["\uad9b","\u1100\u116f\u11ba"],["\uad9c","\u1100\u116f\u11bb"],["\uad9d","\u1100\u116f\u11bc"],["\uad9e","\u1100\u116f\u11bd"],["\uad9f","\u1100\u116f\u11be"],["\uada0","\u1100\u116f\u11bf"],["\uada1","\u1100\u116f\u11c0"],["\uada2","\u1100\u116f\u11c1"],["\uada3","\u1100\u116f\u11c2"],["\uada4","\u1100\u1170"],["\uada5","\u1100\u1170\u11a8"],["\uada6","\u1100\u1170\u11a9"],["\uada7","\u1100\u1170\u11aa"],["\uada8","\u1100\u1170\u11ab"],["\uada9","\u1100\u1170\u11ac"],["\uadaa","\u1100\u1170\u11ad"],["\uadab","\u1100\u1170\u11ae"],["\uadac","\u1100\u1170\u11af"],["\uadad","\u1100\u1170\u11b0"],["\uadae","\u1100\u1170\u11b1"],["\uadaf","\u1100\u1170\u11b2"],["\uadb0","\u1100\u1170\u11b3"],["\uadb1","\u1100\u1170\u11b4"],["\uadb2","\u1100\u1170\u11b5"],["\uadb3","\u1100\u1170\u11b6"],["\uadb4","\u1100\u1170\u11b7"],["\uadb5","\u1100\u1170\u11b8"],["\uadb6","\u1100\u1170\u11b9"],["\uadb7","\u1100\u1170\u11ba"],["\uadb8","\u1100\u1170\u11bb"],["\uadb9","\u1100\u1170\u11bc"],["\uadba","\u1100\u1170\u11bd"],["\uadbb","\u1100\u1170\u11be"],["\uadbc","\u1100\u1170\u11bf"],["\uadbd","\u1100\u1170\u11c0"],["\uadbe","\u1100\u1170\u11c1"],["\uadbf","\u1100\u1170\u11c2"],["\uadc0","\u1100\u1171"],["\uadc1","\u1100\u1171\u11a8"],["\uadc2","\u1100\u1171\u11a9"],["\uadc3","\u1100\u1171\u11aa"],["\uadc4","\u1100\u1171\u11ab"],["\uadc5","\u1100\u1171\u11ac"],["\uadc6","\u1100\u1171\u11ad"],["\uadc7","\u1100\u1171\u11ae"],["\uadc8","\u1100\u1171\u11af"],["\uadc9","\u1100\u1171\u11b0"],["\uadca","\u1100\u1171\u11b1"],["\uadcb","\u1100\u1171\u11b2"],["\uadcc","\u1100\u1171\u11b3"],["\uadcd","\u1100\u1171\u11b4"],["\uadce","\u1100\u1171\u11b5"],["\uadcf","\u1100\u1171\u11b6"],["\uadd0","\u1100\u1171\u11b7"],["\uadd1","\u1100\u1171\u11b8"],["\uadd2","\u1100\u1171\u11b9"],["\uadd3","\u1100\u1171\u11ba"],["\uadd4","\u1100\u1171\u11bb"],["\uadd5","\u1100\u1171\u11bc"],["\uadd6","\u1100\u1171\u11bd"],["\uadd7","\u1100\u1171\u11be"],["\uadd8","\u1100\u1171\u11bf"],["\uadd9","\u1100\u1171\u11c0"],["\uadda","\u1100\u1171\u11c1"],["\uaddb","\u1100\u1171\u11c2"],["\uaddc","\u1100\u1172"],["\uaddd","\u1100\u1172\u11a8"],["\uadde","\u1100\u1172\u11a9"],["\uaddf","\u1100\u1172\u11aa"],["\uade0","\u1100\u1172\u11ab"],["\uade1","\u1100\u1172\u11ac"],["\uade2","\u1100\u1172\u11ad"],["\uade3","\u1100\u1172\u11ae"],["\uade4","\u1100\u1172\u11af"],["\uade5","\u1100\u1172\u11b0"],["\uade6","\u1100\u1172\u11b1"],["\uade7","\u1100\u1172\u11b2"],["\uade8","\u1100\u1172\u11b3"],["\uade9","\u1100\u1172\u11b4"],["\uadea","\u1100\u1172\u11b5"],["\uadeb","\u1100\u1172\u11b6"],["\uadec","\u1100\u1172\u11b7"],["\uaded","\u1100\u1172\u11b8"],["\uadee","\u1100\u1172\u11b9"],["\uadef","\u1100\u1172\u11ba"],["\uadf0","\u1100\u1172\u11bb"],["\uadf1","\u1100\u1172\u11bc"],["\uadf2","\u1100\u1172\u11bd"],["\uadf3","\u1100\u1172\u11be"],["\uadf4","\u1100\u1172\u11bf"],["\uadf5","\u1100\u1172\u11c0"],["\uadf6","\u1100\u1172\u11c1"],["\uadf7","\u1100\u1172\u11c2"],["\uadf8","\u1100\u1173"],["\uadf9","\u1100\u1173\u11a8"],["\uadfa","\u1100\u1173\u11a9"],["\uadfb","\u1100\u1173\u11aa"],["\uadfc","\u1100\u1173\u11ab"],["\uadfd","\u1100\u1173\u11ac"],["\uadfe","\u1100\u1173\u11ad"],["\uadff","\u1100\u1173\u11ae"],["\uae00","\u1100\u1173\u11af"],["\uae01","\u1100\u1173\u11b0"],["\uae02","\u1100\u1173\u11b1"],["\uae03","\u1100\u1173\u11b2"],["\uae04","\u1100\u1173\u11b3"],["\uae05","\u1100\u1173\u11b4"],["\uae06","\u1100\u1173\u11b5"],["\uae07","\u1100\u1173\u11b6"],["\uae08","\u1100\u1173\u11b7"],["\uae09","\u1100\u1173\u11b8"],["\uae0a","\u1100\u1173\u11b9"],["\uae0b","\u1100\u1173\u11ba"],["\uae0c","\u1100\u1173\u11bb"],["\uae0d","\u1100\u1173\u11bc"],["\uae0e","\u1100\u1173\u11bd"],["\uae0f","\u1100\u1173\u11be"],["\uae10","\u1100\u1173\u11bf"],["\uae11","\u1100\u1173\u11c0"],["\uae12","\u1100\u1173\u11c1"],["\uae13","\u1100\u1173\u11c2"],["\uae14","\u1100\u1174"],["\uae15","\u1100\u1174\u11a8"],["\uae16","\u1100\u1174\u11a9"],["\uae17","\u1100\u1174\u11aa"],["\uae18","\u1100\u1174\u11ab"],["\uae19","\u1100\u1174\u11ac"],["\uae1a","\u1100\u1174\u11ad"],["\uae1b","\u1100\u1174\u11ae"],["\uae1c","\u1100\u1174\u11af"],["\uae1d","\u1100\u1174\u11b0"],["\uae1e","\u1100\u1174\u11b1"],["\uae1f","\u1100\u1174\u11b2"],["\uae20","\u1100\u1174\u11b3"],["\uae21","\u1100\u1174\u11b4"],["\uae22","\u1100\u1174\u11b5"],["\uae23","\u1100\u1174\u11b6"],["\uae24","\u1100\u1174\u11b7"],["\uae25","\u1100\u1174\u11b8"],["\uae26","\u1100\u1174\u11b9"],["\uae27","\u1100\u1174\u11ba"],["\uae28","\u1100\u1174\u11bb"],["\uae29","\u1100\u1174\u11bc"],["\uae2a","\u1100\u1174\u11bd"],["\uae2b","\u1100\u1174\u11be"],["\uae2c","\u1100\u1174\u11bf"],["\uae2d","\u1100\u1174\u11c0"],["\uae2e","\u1100\u1174\u11c1"],["\uae2f","\u1100\u1174\u11c2"],["\uae30","\u1100\u1175"],["\uae31","\u1100\u1175\u11a8"],["\uae32","\u1100\u1175\u11a9"],["\uae33","\u1100\u1175\u11aa"],["\uae34","\u1100\u1175\u11ab"],["\uae35","\u1100\u1175\u11ac"],["\uae36","\u1100\u1175\u11ad"],["\uae37","\u1100\u1175\u11ae"],["\uae38","\u1100\u1175\u11af"],["\uae39","\u1100\u1175\u11b0"],["\uae3a","\u1100\u1175\u11b1"],["\uae3b","\u1100\u1175\u11b2"],["\uae3c","\u1100\u1175\u11b3"],["\uae3d","\u1100\u1175\u11b4"],["\uae3e","\u1100\u1175\u11b5"],["\uae3f","\u1100\u1175\u11b6"],["\uae40","\u1100\u1175\u11b7"],["\uae41","\u1100\u1175\u11b8"],["\uae42","\u1100\u1175\u11b9"],["\uae43","\u1100\u1175\u11ba"],["\uae44","\u1100\u1175\u11bb"],["\uae45","\u1100\u1175\u11bc"],["\uae46","\u1100\u1175\u11bd"],["\uae47","\u1100\u1175\u11be"],["\uae48","\u1100\u1175\u11bf"],["\uae49","\u1100\u1175\u11c0"],["\uae4a","\u1100\u1175\u11c1"],["\uae4b","\u1100\u1175\u11c2"],["\uae4c","\u1101\u1161"],["\uae4d","\u1101\u1161\u11a8"],["\uae4e","\u1101\u1161\u11a9"],["\uae4f","\u1101\u1161\u11aa"],["\uae50","\u1101\u1161\u11ab"],["\uae51","\u1101\u1161\u11ac"],["\uae52","\u1101\u1161\u11ad"],["\uae53","\u1101\u1161\u11ae"],["\uae54","\u1101\u1161\u11af"],["\uae55","\u1101\u1161\u11b0"],["\uae56","\u1101\u1161\u11b1"],["\uae57","\u1101\u1161\u11b2"],["\uae58","\u1101\u1161\u11b3"],["\uae59","\u1101\u1161\u11b4"],["\uae5a","\u1101\u1161\u11b5"],["\uae5b","\u1101\u1161\u11b6"],["\uae5c","\u1101\u1161\u11b7"],["\uae5d","\u1101\u1161\u11b8"],["\uae5e","\u1101\u1161\u11b9"],["\uae5f","\u1101\u1161\u11ba"],["\uae60","\u1101\u1161\u11bb"],["\uae61","\u1101\u1161\u11bc"],["\uae62","\u1101\u1161\u11bd"],["\uae63","\u1101\u1161\u11be"],["\uae64","\u1101\u1161\u11bf"],["\uae65","\u1101\u1161\u11c0"],["\uae66","\u1101\u1161\u11c1"],["\uae67","\u1101\u1161\u11c2"],["\uae68","\u1101\u1162"],["\uae69","\u1101\u1162\u11a8"],["\uae6a","\u1101\u1162\u11a9"],["\uae6b","\u1101\u1162\u11aa"],["\uae6c","\u1101\u1162\u11ab"],["\uae6d","\u1101\u1162\u11ac"],["\uae6e","\u1101\u1162\u11ad"],["\uae6f","\u1101\u1162\u11ae"],["\uae70","\u1101\u1162\u11af"],["\uae71","\u1101\u1162\u11b0"],["\uae72","\u1101\u1162\u11b1"],["\uae73","\u1101\u1162\u11b2"],["\uae74","\u1101\u1162\u11b3"],["\uae75","\u1101\u1162\u11b4"],["\uae76","\u1101\u1162\u11b5"],["\uae77","\u1101\u1162\u11b6"],["\uae78","\u1101\u1162\u11b7"],["\uae79","\u1101\u1162\u11b8"],["\uae7a","\u1101\u1162\u11b9"],["\uae7b","\u1101\u1162\u11ba"],["\uae7c","\u1101\u1162\u11bb"],["\uae7d","\u1101\u1162\u11bc"],["\uae7e","\u1101\u1162\u11bd"],["\uae7f","\u1101\u1162\u11be"],["\uae80","\u1101\u1162\u11bf"],["\uae81","\u1101\u1162\u11c0"],["\uae82","\u1101\u1162\u11c1"],["\uae83","\u1101\u1162\u11c2"],["\uae84","\u1101\u1163"],["\uae85","\u1101\u1163\u11a8"],["\uae86","\u1101\u1163\u11a9"],["\uae87","\u1101\u1163\u11aa"],["\uae88","\u1101\u1163\u11ab"],["\uae89","\u1101\u1163\u11ac"],["\uae8a","\u1101\u1163\u11ad"],["\uae8b","\u1101\u1163\u11ae"],["\uae8c","\u1101\u1163\u11af"],["\uae8d","\u1101\u1163\u11b0"],["\uae8e","\u1101\u1163\u11b1"],["\uae8f","\u1101\u1163\u11b2"],["\uae90","\u1101\u1163\u11b3"],["\uae91","\u1101\u1163\u11b4"],["\uae92","\u1101\u1163\u11b5"],["\uae93","\u1101\u1163\u11b6"],["\uae94","\u1101\u1163\u11b7"],["\uae95","\u1101\u1163\u11b8"],["\uae96","\u1101\u1163\u11b9"],["\uae97","\u1101\u1163\u11ba"],["\uae98","\u1101\u1163\u11bb"],["\uae99","\u1101\u1163\u11bc"],["\uae9a","\u1101\u1163\u11bd"],["\uae9b","\u1101\u1163\u11be"],["\uae9c","\u1101\u1163\u11bf"],["\uae9d","\u1101\u1163\u11c0"],["\uae9e","\u1101\u1163\u11c1"],["\uae9f","\u1101\u1163\u11c2"],["\uaea0","\u1101\u1164"],["\uaea1","\u1101\u1164\u11a8"],["\uaea2","\u1101\u1164\u11a9"],["\uaea3","\u1101\u1164\u11aa"],["\uaea4","\u1101\u1164\u11ab"],["\uaea5","\u1101\u1164\u11ac"],["\uaea6","\u1101\u1164\u11ad"],["\uaea7","\u1101\u1164\u11ae"],["\uaea8","\u1101\u1164\u11af"],["\uaea9","\u1101\u1164\u11b0"],["\uaeaa","\u1101\u1164\u11b1"],["\uaeab","\u1101\u1164\u11b2"],["\uaeac","\u1101\u1164\u11b3"],["\uaead","\u1101\u1164\u11b4"],["\uaeae","\u1101\u1164\u11b5"],["\uaeaf","\u1101\u1164\u11b6"],["\uaeb0","\u1101\u1164\u11b7"],["\uaeb1","\u1101\u1164\u11b8"],["\uaeb2","\u1101\u1164\u11b9"],["\uaeb3","\u1101\u1164\u11ba"],["\uaeb4","\u1101\u1164\u11bb"],["\uaeb5","\u1101\u1164\u11bc"],["\uaeb6","\u1101\u1164\u11bd"],["\uaeb7","\u1101\u1164\u11be"],["\uaeb8","\u1101\u1164\u11bf"],["\uaeb9","\u1101\u1164\u11c0"],["\uaeba","\u1101\u1164\u11c1"],["\uaebb","\u1101\u1164\u11c2"],["\uaebc","\u1101\u1165"],["\uaebd","\u1101\u1165\u11a8"],["\uaebe","\u1101\u1165\u11a9"],["\uaebf","\u1101\u1165\u11aa"],["\uaec0","\u1101\u1165\u11ab"],["\uaec1","\u1101\u1165\u11ac"],["\uaec2","\u1101\u1165\u11ad"],["\uaec3","\u1101\u1165\u11ae"],["\uaec4","\u1101\u1165\u11af"],["\uaec5","\u1101\u1165\u11b0"],["\uaec6","\u1101\u1165\u11b1"],["\uaec7","\u1101\u1165\u11b2"],["\uaec8","\u1101\u1165\u11b3"],["\uaec9","\u1101\u1165\u11b4"],["\uaeca","\u1101\u1165\u11b5"],["\uaecb","\u1101\u1165\u11b6"],["\uaecc","\u1101\u1165\u11b7"],["\uaecd","\u1101\u1165\u11b8"],["\uaece","\u1101\u1165\u11b9"],["\uaecf","\u1101\u1165\u11ba"],["\uaed0","\u1101\u1165\u11bb"],["\uaed1","\u1101\u1165\u11bc"],["\uaed2","\u1101\u1165\u11bd"],["\uaed3","\u1101\u1165\u11be"],["\uaed4","\u1101\u1165\u11bf"],["\uaed5","\u1101\u1165\u11c0"],["\uaed6","\u1101\u1165\u11c1"],["\uaed7","\u1101\u1165\u11c2"],["\uaed8","\u1101\u1166"],["\uaed9","\u1101\u1166\u11a8"],["\uaeda","\u1101\u1166\u11a9"],["\uaedb","\u1101\u1166\u11aa"],["\uaedc","\u1101\u1166\u11ab"],["\uaedd","\u1101\u1166\u11ac"],["\uaede","\u1101\u1166\u11ad"],["\uaedf","\u1101\u1166\u11ae"],["\uaee0","\u1101\u1166\u11af"],["\uaee1","\u1101\u1166\u11b0"],["\uaee2","\u1101\u1166\u11b1"],["\uaee3","\u1101\u1166\u11b2"],["\uaee4","\u1101\u1166\u11b3"],["\uaee5","\u1101\u1166\u11b4"],["\uaee6","\u1101\u1166\u11b5"],["\uaee7","\u1101\u1166\u11b6"],["\uaee8","\u1101\u1166\u11b7"],["\uaee9","\u1101\u1166\u11b8"],["\uaeea","\u1101\u1166\u11b9"],["\uaeeb","\u1101\u1166\u11ba"],["\uaeec","\u1101\u1166\u11bb"],["\uaeed","\u1101\u1166\u11bc"],["\uaeee","\u1101\u1166\u11bd"],["\uaeef","\u1101\u1166\u11be"],["\uaef0","\u1101\u1166\u11bf"],["\uaef1","\u1101\u1166\u11c0"],["\uaef2","\u1101\u1166\u11c1"],["\uaef3","\u1101\u1166\u11c2"],["\uaef4","\u1101\u1167"],["\uaef5","\u1101\u1167\u11a8"],["\uaef6","\u1101\u1167\u11a9"],["\uaef7","\u1101\u1167\u11aa"],["\uaef8","\u1101\u1167\u11ab"],["\uaef9","\u1101\u1167\u11ac"],["\uaefa","\u1101\u1167\u11ad"],["\uaefb","\u1101\u1167\u11ae"],["\uaefc","\u1101\u1167\u11af"],["\uaefd","\u1101\u1167\u11b0"],["\uaefe","\u1101\u1167\u11b1"],["\uaeff","\u1101\u1167\u11b2"],["\uaf00","\u1101\u1167\u11b3"],["\uaf01","\u1101\u1167\u11b4"],["\uaf02","\u1101\u1167\u11b5"],["\uaf03","\u1101\u1167\u11b6"],["\uaf04","\u1101\u1167\u11b7"],["\uaf05","\u1101\u1167\u11b8"],["\uaf06","\u1101\u1167\u11b9"],["\uaf07","\u1101\u1167\u11ba"],["\uaf08","\u1101\u1167\u11bb"],["\uaf09","\u1101\u1167\u11bc"],["\uaf0a","\u1101\u1167\u11bd"],["\uaf0b","\u1101\u1167\u11be"],["\uaf0c","\u1101\u1167\u11bf"],["\uaf0d","\u1101\u1167\u11c0"],["\uaf0e","\u1101\u1167\u11c1"],["\uaf0f","\u1101\u1167\u11c2"],["\uaf10","\u1101\u1168"],["\uaf11","\u1101\u1168\u11a8"],["\uaf12","\u1101\u1168\u11a9"],["\uaf13","\u1101\u1168\u11aa"],["\uaf14","\u1101\u1168\u11ab"],["\uaf15","\u1101\u1168\u11ac"],["\uaf16","\u1101\u1168\u11ad"],["\uaf17","\u1101\u1168\u11ae"],["\uaf18","\u1101\u1168\u11af"],["\uaf19","\u1101\u1168\u11b0"],["\uaf1a","\u1101\u1168\u11b1"],["\uaf1b","\u1101\u1168\u11b2"],["\uaf1c","\u1101\u1168\u11b3"],["\uaf1d","\u1101\u1168\u11b4"],["\uaf1e","\u1101\u1168\u11b5"],["\uaf1f","\u1101\u1168\u11b6"],["\uaf20","\u1101\u1168\u11b7"],["\uaf21","\u1101\u1168\u11b8"],["\uaf22","\u1101\u1168\u11b9"],["\uaf23","\u1101\u1168\u11ba"],["\uaf24","\u1101\u1168\u11bb"],["\uaf25","\u1101\u1168\u11bc"],["\uaf26","\u1101\u1168\u11bd"],["\uaf27","\u1101\u1168\u11be"],["\uaf28","\u1101\u1168\u11bf"],["\uaf29","\u1101\u1168\u11c0"],["\uaf2a","\u1101\u1168\u11c1"],["\uaf2b","\u1101\u1168\u11c2"],["\uaf2c","\u1101\u1169"],["\uaf2d","\u1101\u1169\u11a8"],["\uaf2e","\u1101\u1169\u11a9"],["\uaf2f","\u1101\u1169\u11aa"],["\uaf30","\u1101\u1169\u11ab"],["\uaf31","\u1101\u1169\u11ac"],["\uaf32","\u1101\u1169\u11ad"],["\uaf33","\u1101\u1169\u11ae"],["\uaf34","\u1101\u1169\u11af"],["\uaf35","\u1101\u1169\u11b0"],["\uaf36","\u1101\u1169\u11b1"],["\uaf37","\u1101\u1169\u11b2"],["\uaf38","\u1101\u1169\u11b3"],["\uaf39","\u1101\u1169\u11b4"],["\uaf3a","\u1101\u1169\u11b5"],["\uaf3b","\u1101\u1169\u11b6"],["\uaf3c","\u1101\u1169\u11b7"],["\uaf3d","\u1101\u1169\u11b8"],["\uaf3e","\u1101\u1169\u11b9"],["\uaf3f","\u1101\u1169\u11ba"],["\uaf40","\u1101\u1169\u11bb"],["\uaf41","\u1101\u1169\u11bc"],["\uaf42","\u1101\u1169\u11bd"],["\uaf43","\u1101\u1169\u11be"],["\uaf44","\u1101\u1169\u11bf"],["\uaf45","\u1101\u1169\u11c0"],["\uaf46","\u1101\u1169\u11c1"],["\uaf47","\u1101\u1169\u11c2"],["\uaf48","\u1101\u116a"],["\uaf49","\u1101\u116a\u11a8"],["\uaf4a","\u1101\u116a\u11a9"],["\uaf4b","\u1101\u116a\u11aa"],["\uaf4c","\u1101\u116a\u11ab"],["\uaf4d","\u1101\u116a\u11ac"],["\uaf4e","\u1101\u116a\u11ad"],["\uaf4f","\u1101\u116a\u11ae"],["\uaf50","\u1101\u116a\u11af"],["\uaf51","\u1101\u116a\u11b0"],["\uaf52","\u1101\u116a\u11b1"],["\uaf53","\u1101\u116a\u11b2"],["\uaf54","\u1101\u116a\u11b3"],["\uaf55","\u1101\u116a\u11b4"],["\uaf56","\u1101\u116a\u11b5"],["\uaf57","\u1101\u116a\u11b6"],["\uaf58","\u1101\u116a\u11b7"],["\uaf59","\u1101\u116a\u11b8"],["\uaf5a","\u1101\u116a\u11b9"],["\uaf5b","\u1101\u116a\u11ba"],["\uaf5c","\u1101\u116a\u11bb"],["\uaf5d","\u1101\u116a\u11bc"],["\uaf5e","\u1101\u116a\u11bd"],["\uaf5f","\u1101\u116a\u11be"],["\uaf60","\u1101\u116a\u11bf"],["\uaf61","\u1101\u116a\u11c0"],["\uaf62","\u1101\u116a\u11c1"],["\uaf63","\u1101\u116a\u11c2"],["\uaf64","\u1101\u116b"],["\uaf65","\u1101\u116b\u11a8"],["\uaf66","\u1101\u116b\u11a9"],["\uaf67","\u1101\u116b\u11aa"],["\uaf68","\u1101\u116b\u11ab"],["\uaf69","\u1101\u116b\u11ac"],["\uaf6a","\u1101\u116b\u11ad"],["\uaf6b","\u1101\u116b\u11ae"],["\uaf6c","\u1101\u116b\u11af"],["\uaf6d","\u1101\u116b\u11b0"],["\uaf6e","\u1101\u116b\u11b1"],["\uaf6f","\u1101\u116b\u11b2"],["\uaf70","\u1101\u116b\u11b3"],["\uaf71","\u1101\u116b\u11b4"],["\uaf72","\u1101\u116b\u11b5"],["\uaf73","\u1101\u116b\u11b6"],["\uaf74","\u1101\u116b\u11b7"],["\uaf75","\u1101\u116b\u11b8"],["\uaf76","\u1101\u116b\u11b9"],["\uaf77","\u1101\u116b\u11ba"],["\uaf78","\u1101\u116b\u11bb"],["\uaf79","\u1101\u116b\u11bc"],["\uaf7a","\u1101\u116b\u11bd"],["\uaf7b","\u1101\u116b\u11be"],["\uaf7c","\u1101\u116b\u11bf"],["\uaf7d","\u1101\u116b\u11c0"],["\uaf7e","\u1101\u116b\u11c1"],["\uaf7f","\u1101\u116b\u11c2"],["\uaf80","\u1101\u116c"],["\uaf81","\u1101\u116c\u11a8"],["\uaf82","\u1101\u116c\u11a9"],["\uaf83","\u1101\u116c\u11aa"],["\uaf84","\u1101\u116c\u11ab"],["\uaf85","\u1101\u116c\u11ac"],["\uaf86","\u1101\u116c\u11ad"],["\uaf87","\u1101\u116c\u11ae"],["\uaf88","\u1101\u116c\u11af"],["\uaf89","\u1101\u116c\u11b0"],["\uaf8a","\u1101\u116c\u11b1"],["\uaf8b","\u1101\u116c\u11b2"],["\uaf8c","\u1101\u116c\u11b3"],["\uaf8d","\u1101\u116c\u11b4"],["\uaf8e","\u1101\u116c\u11b5"],["\uaf8f","\u1101\u116c\u11b6"],["\uaf90","\u1101\u116c\u11b7"],["\uaf91","\u1101\u116c\u11b8"],["\uaf92","\u1101\u116c\u11b9"],["\uaf93","\u1101\u116c\u11ba"],["\uaf94","\u1101\u116c\u11bb"],["\uaf95","\u1101\u116c\u11bc"],["\uaf96","\u1101\u116c\u11bd"],["\uaf97","\u1101\u116c\u11be"],["\uaf98","\u1101\u116c\u11bf"],["\uaf99","\u1101\u116c\u11c0"],["\uaf9a","\u1101\u116c\u11c1"],["\uaf9b","\u1101\u116c\u11c2"],["\uaf9c","\u1101\u116d"],["\uaf9d","\u1101\u116d\u11a8"],["\uaf9e","\u1101\u116d\u11a9"],["\uaf9f","\u1101\u116d\u11aa"],["\uafa0","\u1101\u116d\u11ab"],["\uafa1","\u1101\u116d\u11ac"],["\uafa2","\u1101\u116d\u11ad"],["\uafa3","\u1101\u116d\u11ae"],["\uafa4","\u1101\u116d\u11af"],["\uafa5","\u1101\u116d\u11b0"],["\uafa6","\u1101\u116d\u11b1"],["\uafa7","\u1101\u116d\u11b2"],["\uafa8","\u1101\u116d\u11b3"],["\uafa9","\u1101\u116d\u11b4"],["\uafaa","\u1101\u116d\u11b5"],["\uafab","\u1101\u116d\u11b6"],["\uafac","\u1101\u116d\u11b7"],["\uafad","\u1101\u116d\u11b8"],["\uafae","\u1101\u116d\u11b9"],["\uafaf","\u1101\u116d\u11ba"],["\uafb0","\u1101\u116d\u11bb"],["\uafb1","\u1101\u116d\u11bc"],["\uafb2","\u1101\u116d\u11bd"],["\uafb3","\u1101\u116d\u11be"],["\uafb4","\u1101\u116d\u11bf"],["\uafb5","\u1101\u116d\u11c0"],["\uafb6","\u1101\u116d\u11c1"],["\uafb7","\u1101\u116d\u11c2"],["\uafb8","\u1101\u116e"],["\uafb9","\u1101\u116e\u11a8"],["\uafba","\u1101\u116e\u11a9"],["\uafbb","\u1101\u116e\u11aa"],["\uafbc","\u1101\u116e\u11ab"],["\uafbd","\u1101\u116e\u11ac"],["\uafbe","\u1101\u116e\u11ad"],["\uafbf","\u1101\u116e\u11ae"],["\uafc0","\u1101\u116e\u11af"],["\uafc1","\u1101\u116e\u11b0"],["\uafc2","\u1101\u116e\u11b1"],["\uafc3","\u1101\u116e\u11b2"],["\uafc4","\u1101\u116e\u11b3"],["\uafc5","\u1101\u116e\u11b4"],["\uafc6","\u1101\u116e\u11b5"],["\uafc7","\u1101\u116e\u11b6"],["\uafc8","\u1101\u116e\u11b7"],["\uafc9","\u1101\u116e\u11b8"],["\uafca","\u1101\u116e\u11b9"],["\uafcb","\u1101\u116e\u11ba"],["\uafcc","\u1101\u116e\u11bb"],["\uafcd","\u1101\u116e\u11bc"],["\uafce","\u1101\u116e\u11bd"],["\uafcf","\u1101\u116e\u11be"],["\uafd0","\u1101\u116e\u11bf"],["\uafd1","\u1101\u116e\u11c0"],["\uafd2","\u1101\u116e\u11c1"],["\uafd3","\u1101\u116e\u11c2"],["\uafd4","\u1101\u116f"],["\uafd5","\u1101\u116f\u11a8"],["\uafd6","\u1101\u116f\u11a9"],["\uafd7","\u1101\u116f\u11aa"],["\uafd8","\u1101\u116f\u11ab"],["\uafd9","\u1101\u116f\u11ac"],["\uafda","\u1101\u116f\u11ad"],["\uafdb","\u1101\u116f\u11ae"],["\uafdc","\u1101\u116f\u11af"],["\uafdd","\u1101\u116f\u11b0"],["\uafde","\u1101\u116f\u11b1"],["\uafdf","\u1101\u116f\u11b2"],["\uafe0","\u1101\u116f\u11b3"],["\uafe1","\u1101\u116f\u11b4"],["\uafe2","\u1101\u116f\u11b5"],["\uafe3","\u1101\u116f\u11b6"],["\uafe4","\u1101\u116f\u11b7"],["\uafe5","\u1101\u116f\u11b8"],["\uafe6","\u1101\u116f\u11b9"],["\uafe7","\u1101\u116f\u11ba"],["\uafe8","\u1101\u116f\u11bb"],["\uafe9","\u1101\u116f\u11bc"],["\uafea","\u1101\u116f\u11bd"],["\uafeb","\u1101\u116f\u11be"],["\uafec","\u1101\u116f\u11bf"],["\uafed","\u1101\u116f\u11c0"],["\uafee","\u1101\u116f\u11c1"],["\uafef","\u1101\u116f\u11c2"],["\uaff0","\u1101\u1170"],["\uaff1","\u1101\u1170\u11a8"],["\uaff2","\u1101\u1170\u11a9"],["\uaff3","\u1101\u1170\u11aa"],["\uaff4","\u1101\u1170\u11ab"],["\uaff5","\u1101\u1170\u11ac"],["\uaff6","\u1101\u1170\u11ad"],["\uaff7","\u1101\u1170\u11ae"],["\uaff8","\u1101\u1170\u11af"],["\uaff9","\u1101\u1170\u11b0"],["\uaffa","\u1101\u1170\u11b1"],["\uaffb","\u1101\u1170\u11b2"],["\uaffc","\u1101\u1170\u11b3"],["\uaffd","\u1101\u1170\u11b4"],["\uaffe","\u1101\u1170\u11b5"],["\uafff","\u1101\u1170\u11b6"],["\ub000","\u1101\u1170\u11b7"],["\ub001","\u1101\u1170\u11b8"],["\ub002","\u1101\u1170\u11b9"],["\ub003","\u1101\u1170\u11ba"],["\ub004","\u1101\u1170\u11bb"],["\ub005","\u1101\u1170\u11bc"],["\ub006","\u1101\u1170\u11bd"],["\ub007","\u1101\u1170\u11be"],["\ub008","\u1101\u1170\u11bf"],["\ub009","\u1101\u1170\u11c0"],["\ub00a","\u1101\u1170\u11c1"],["\ub00b","\u1101\u1170\u11c2"],["\ub00c","\u1101\u1171"],["\ub00d","\u1101\u1171\u11a8"],["\ub00e","\u1101\u1171\u11a9"],["\ub00f","\u1101\u1171\u11aa"],["\ub010","\u1101\u1171\u11ab"],["\ub011","\u1101\u1171\u11ac"],["\ub012","\u1101\u1171\u11ad"],["\ub013","\u1101\u1171\u11ae"],["\ub014","\u1101\u1171\u11af"],["\ub015","\u1101\u1171\u11b0"],["\ub016","\u1101\u1171\u11b1"],["\ub017","\u1101\u1171\u11b2"],["\ub018","\u1101\u1171\u11b3"],["\ub019","\u1101\u1171\u11b4"],["\ub01a","\u1101\u1171\u11b5"],["\ub01b","\u1101\u1171\u11b6"],["\ub01c","\u1101\u1171\u11b7"],["\ub01d","\u1101\u1171\u11b8"],["\ub01e","\u1101\u1171\u11b9"],["\ub01f","\u1101\u1171\u11ba"],["\ub020","\u1101\u1171\u11bb"],["\ub021","\u1101\u1171\u11bc"],["\ub022","\u1101\u1171\u11bd"],["\ub023","\u1101\u1171\u11be"],["\ub024","\u1101\u1171\u11bf"],["\ub025","\u1101\u1171\u11c0"],["\ub026","\u1101\u1171\u11c1"],["\ub027","\u1101\u1171\u11c2"],["\ub028","\u1101\u1172"],["\ub029","\u1101\u1172\u11a8"],["\ub02a","\u1101\u1172\u11a9"],["\ub02b","\u1101\u1172\u11aa"],["\ub02c","\u1101\u1172\u11ab"],["\ub02d","\u1101\u1172\u11ac"],["\ub02e","\u1101\u1172\u11ad"],["\ub02f","\u1101\u1172\u11ae"],["\ub030","\u1101\u1172\u11af"],["\ub031","\u1101\u1172\u11b0"],["\ub032","\u1101\u1172\u11b1"],["\ub033","\u1101\u1172\u11b2"],["\ub034","\u1101\u1172\u11b3"],["\ub035","\u1101\u1172\u11b4"],["\ub036","\u1101\u1172\u11b5"],["\ub037","\u1101\u1172\u11b6"],["\ub038","\u1101\u1172\u11b7"],["\ub039","\u1101\u1172\u11b8"],["\ub03a","\u1101\u1172\u11b9"],["\ub03b","\u1101\u1172\u11ba"],["\ub03c","\u1101\u1172\u11bb"],["\ub03d","\u1101\u1172\u11bc"],["\ub03e","\u1101\u1172\u11bd"],["\ub03f","\u1101\u1172\u11be"],["\ub040","\u1101\u1172\u11bf"],["\ub041","\u1101\u1172\u11c0"],["\ub042","\u1101\u1172\u11c1"],["\ub043","\u1101\u1172\u11c2"],["\ub044","\u1101\u1173"],["\ub045","\u1101\u1173\u11a8"],["\ub046","\u1101\u1173\u11a9"],["\ub047","\u1101\u1173\u11aa"],["\ub048","\u1101\u1173\u11ab"],["\ub049","\u1101\u1173\u11ac"],["\ub04a","\u1101\u1173\u11ad"],["\ub04b","\u1101\u1173\u11ae"],["\ub04c","\u1101\u1173\u11af"],["\ub04d","\u1101\u1173\u11b0"],["\ub04e","\u1101\u1173\u11b1"],["\ub04f","\u1101\u1173\u11b2"],["\ub050","\u1101\u1173\u11b3"],["\ub051","\u1101\u1173\u11b4"],["\ub052","\u1101\u1173\u11b5"],["\ub053","\u1101\u1173\u11b6"],["\ub054","\u1101\u1173\u11b7"],["\ub055","\u1101\u1173\u11b8"],["\ub056","\u1101\u1173\u11b9"],["\ub057","\u1101\u1173\u11ba"],["\ub058","\u1101\u1173\u11bb"],["\ub059","\u1101\u1173\u11bc"],["\ub05a","\u1101\u1173\u11bd"],["\ub05b","\u1101\u1173\u11be"],["\ub05c","\u1101\u1173\u11bf"],["\ub05d","\u1101\u1173\u11c0"],["\ub05e","\u1101\u1173\u11c1"],["\ub05f","\u1101\u1173\u11c2"],["\ub060","\u1101\u1174"],["\ub061","\u1101\u1174\u11a8"],["\ub062","\u1101\u1174\u11a9"],["\ub063","\u1101\u1174\u11aa"],["\ub064","\u1101\u1174\u11ab"],["\ub065","\u1101\u1174\u11ac"],["\ub066","\u1101\u1174\u11ad"],["\ub067","\u1101\u1174\u11ae"],["\ub068","\u1101\u1174\u11af"],["\ub069","\u1101\u1174\u11b0"],["\ub06a","\u1101\u1174\u11b1"],["\ub06b","\u1101\u1174\u11b2"],["\ub06c","\u1101\u1174\u11b3"],["\ub06d","\u1101\u1174\u11b4"],["\ub06e","\u1101\u1174\u11b5"],["\ub06f","\u1101\u1174\u11b6"],["\ub070","\u1101\u1174\u11b7"],["\ub071","\u1101\u1174\u11b8"],["\ub072","\u1101\u1174\u11b9"],["\ub073","\u1101\u1174\u11ba"],["\ub074","\u1101\u1174\u11bb"],["\ub075","\u1101\u1174\u11bc"],["\ub076","\u1101\u1174\u11bd"],["\ub077","\u1101\u1174\u11be"],["\ub078","\u1101\u1174\u11bf"],["\ub079","\u1101\u1174\u11c0"],["\ub07a","\u1101\u1174\u11c1"],["\ub07b","\u1101\u1174\u11c2"],["\ub07c","\u1101\u1175"],["\ub07d","\u1101\u1175\u11a8"],["\ub07e","\u1101\u1175\u11a9"],["\ub07f","\u1101\u1175\u11aa"],["\ub080","\u1101\u1175\u11ab"],["\ub081","\u1101\u1175\u11ac"],["\ub082","\u1101\u1175\u11ad"],["\ub083","\u1101\u1175\u11ae"],["\ub084","\u1101\u1175\u11af"],["\ub085","\u1101\u1175\u11b0"],["\ub086","\u1101\u1175\u11b1"],["\ub087","\u1101\u1175\u11b2"],["\ub088","\u1101\u1175\u11b3"],["\ub089","\u1101\u1175\u11b4"],["\ub08a","\u1101\u1175\u11b5"],["\ub08b","\u1101\u1175\u11b6"],["\ub08c","\u1101\u1175\u11b7"],["\ub08d","\u1101\u1175\u11b8"],["\ub08e","\u1101\u1175\u11b9"],["\ub08f","\u1101\u1175\u11ba"],["\ub090","\u1101\u1175\u11bb"],["\ub091","\u1101\u1175\u11bc"],["\ub092","\u1101\u1175\u11bd"],["\ub093","\u1101\u1175\u11be"],["\ub094","\u1101\u1175\u11bf"],["\ub095","\u1101\u1175\u11c0"],["\ub096","\u1101\u1175\u11c1"],["\ub097","\u1101\u1175\u11c2"],["\ub098","\u1102\u1161"],["\ub099","\u1102\u1161\u11a8"],["\ub09a","\u1102\u1161\u11a9"],["\ub09b","\u1102\u1161\u11aa"],["\ub09c","\u1102\u1161\u11ab"],["\ub09d","\u1102\u1161\u11ac"],["\ub09e","\u1102\u1161\u11ad"],["\ub09f","\u1102\u1161\u11ae"],["\ub0a0","\u1102\u1161\u11af"],["\ub0a1","\u1102\u1161\u11b0"],["\ub0a2","\u1102\u1161\u11b1"],["\ub0a3","\u1102\u1161\u11b2"],["\ub0a4","\u1102\u1161\u11b3"],["\ub0a5","\u1102\u1161\u11b4"],["\ub0a6","\u1102\u1161\u11b5"],["\ub0a7","\u1102\u1161\u11b6"],["\ub0a8","\u1102\u1161\u11b7"],["\ub0a9","\u1102\u1161\u11b8"],["\ub0aa","\u1102\u1161\u11b9"],["\ub0ab","\u1102\u1161\u11ba"],["\ub0ac","\u1102\u1161\u11bb"],["\ub0ad","\u1102\u1161\u11bc"],["\ub0ae","\u1102\u1161\u11bd"],["\ub0af","\u1102\u1161\u11be"],["\ub0b0","\u1102\u1161\u11bf"],["\ub0b1","\u1102\u1161\u11c0"],["\ub0b2","\u1102\u1161\u11c1"],["\ub0b3","\u1102\u1161\u11c2"],["\ub0b4","\u1102\u1162"],["\ub0b5","\u1102\u1162\u11a8"],["\ub0b6","\u1102\u1162\u11a9"],["\ub0b7","\u1102\u1162\u11aa"],["\ub0b8","\u1102\u1162\u11ab"],["\ub0b9","\u1102\u1162\u11ac"],["\ub0ba","\u1102\u1162\u11ad"],["\ub0bb","\u1102\u1162\u11ae"],["\ub0bc","\u1102\u1162\u11af"],["\ub0bd","\u1102\u1162\u11b0"],["\ub0be","\u1102\u1162\u11b1"],["\ub0bf","\u1102\u1162\u11b2"],["\ub0c0","\u1102\u1162\u11b3"],["\ub0c1","\u1102\u1162\u11b4"],["\ub0c2","\u1102\u1162\u11b5"],["\ub0c3","\u1102\u1162\u11b6"],["\ub0c4","\u1102\u1162\u11b7"],["\ub0c5","\u1102\u1162\u11b8"],["\ub0c6","\u1102\u1162\u11b9"],["\ub0c7","\u1102\u1162\u11ba"],["\ub0c8","\u1102\u1162\u11bb"],["\ub0c9","\u1102\u1162\u11bc"],["\ub0ca","\u1102\u1162\u11bd"],["\ub0cb","\u1102\u1162\u11be"],["\ub0cc","\u1102\u1162\u11bf"],["\ub0cd","\u1102\u1162\u11c0"],["\ub0ce","\u1102\u1162\u11c1"],["\ub0cf","\u1102\u1162\u11c2"],["\ub0d0","\u1102\u1163"],["\ub0d1","\u1102\u1163\u11a8"],["\ub0d2","\u1102\u1163\u11a9"],["\ub0d3","\u1102\u1163\u11aa"],["\ub0d4","\u1102\u1163\u11ab"],["\ub0d5","\u1102\u1163\u11ac"],["\ub0d6","\u1102\u1163\u11ad"],["\ub0d7","\u1102\u1163\u11ae"],["\ub0d8","\u1102\u1163\u11af"],["\ub0d9","\u1102\u1163\u11b0"],["\ub0da","\u1102\u1163\u11b1"],["\ub0db","\u1102\u1163\u11b2"],["\ub0dc","\u1102\u1163\u11b3"],["\ub0dd","\u1102\u1163\u11b4"],["\ub0de","\u1102\u1163\u11b5"],["\ub0df","\u1102\u1163\u11b6"],["\ub0e0","\u1102\u1163\u11b7"],["\ub0e1","\u1102\u1163\u11b8"],["\ub0e2","\u1102\u1163\u11b9"],["\ub0e3","\u1102\u1163\u11ba"],["\ub0e4","\u1102\u1163\u11bb"],["\ub0e5","\u1102\u1163\u11bc"],["\ub0e6","\u1102\u1163\u11bd"],["\ub0e7","\u1102\u1163\u11be"],["\ub0e8","\u1102\u1163\u11bf"],["\ub0e9","\u1102\u1163\u11c0"],["\ub0ea","\u1102\u1163\u11c1"],["\ub0eb","\u1102\u1163\u11c2"],["\ub0ec","\u1102\u1164"],["\ub0ed","\u1102\u1164\u11a8"],["\ub0ee","\u1102\u1164\u11a9"],["\ub0ef","\u1102\u1164\u11aa"],["\ub0f0","\u1102\u1164\u11ab"],["\ub0f1","\u1102\u1164\u11ac"],["\ub0f2","\u1102\u1164\u11ad"],["\ub0f3","\u1102\u1164\u11ae"],["\ub0f4","\u1102\u1164\u11af"],["\ub0f5","\u1102\u1164\u11b0"],["\ub0f6","\u1102\u1164\u11b1"],["\ub0f7","\u1102\u1164\u11b2"],["\ub0f8","\u1102\u1164\u11b3"],["\ub0f9","\u1102\u1164\u11b4"],["\ub0fa","\u1102\u1164\u11b5"],["\ub0fb","\u1102\u1164\u11b6"],["\ub0fc","\u1102\u1164\u11b7"],["\ub0fd","\u1102\u1164\u11b8"],["\ub0fe","\u1102\u1164\u11b9"],["\ub0ff","\u1102\u1164\u11ba"],["\ub100","\u1102\u1164\u11bb"],["\ub101","\u1102\u1164\u11bc"],["\ub102","\u1102\u1164\u11bd"],["\ub103","\u1102\u1164\u11be"],["\ub104","\u1102\u1164\u11bf"],["\ub105","\u1102\u1164\u11c0"],["\ub106","\u1102\u1164\u11c1"],["\ub107","\u1102\u1164\u11c2"],["\ub108","\u1102\u1165"],["\ub109","\u1102\u1165\u11a8"],["\ub10a","\u1102\u1165\u11a9"],["\ub10b","\u1102\u1165\u11aa"],["\ub10c","\u1102\u1165\u11ab"],["\ub10d","\u1102\u1165\u11ac"],["\ub10e","\u1102\u1165\u11ad"],["\ub10f","\u1102\u1165\u11ae"],["\ub110","\u1102\u1165\u11af"],["\ub111","\u1102\u1165\u11b0"],["\ub112","\u1102\u1165\u11b1"],["\ub113","\u1102\u1165\u11b2"],["\ub114","\u1102\u1165\u11b3"],["\ub115","\u1102\u1165\u11b4"],["\ub116","\u1102\u1165\u11b5"],["\ub117","\u1102\u1165\u11b6"],["\ub118","\u1102\u1165\u11b7"],["\ub119","\u1102\u1165\u11b8"],["\ub11a","\u1102\u1165\u11b9"],["\ub11b","\u1102\u1165\u11ba"],["\ub11c","\u1102\u1165\u11bb"],["\ub11d","\u1102\u1165\u11bc"],["\ub11e","\u1102\u1165\u11bd"],["\ub11f","\u1102\u1165\u11be"],["\ub120","\u1102\u1165\u11bf"],["\ub121","\u1102\u1165\u11c0"],["\ub122","\u1102\u1165\u11c1"],["\ub123","\u1102\u1165\u11c2"],["\ub124","\u1102\u1166"],["\ub125","\u1102\u1166\u11a8"],["\ub126","\u1102\u1166\u11a9"],["\ub127","\u1102\u1166\u11aa"],["\ub128","\u1102\u1166\u11ab"],["\ub129","\u1102\u1166\u11ac"],["\ub12a","\u1102\u1166\u11ad"],["\ub12b","\u1102\u1166\u11ae"],["\ub12c","\u1102\u1166\u11af"],["\ub12d","\u1102\u1166\u11b0"],["\ub12e","\u1102\u1166\u11b1"],["\ub12f","\u1102\u1166\u11b2"],["\ub130","\u1102\u1166\u11b3"],["\ub131","\u1102\u1166\u11b4"],["\ub132","\u1102\u1166\u11b5"],["\ub133","\u1102\u1166\u11b6"],["\ub134","\u1102\u1166\u11b7"],["\ub135","\u1102\u1166\u11b8"],["\ub136","\u1102\u1166\u11b9"],["\ub137","\u1102\u1166\u11ba"],["\ub138","\u1102\u1166\u11bb"],["\ub139","\u1102\u1166\u11bc"],["\ub13a","\u1102\u1166\u11bd"],["\ub13b","\u1102\u1166\u11be"],["\ub13c","\u1102\u1166\u11bf"],["\ub13d","\u1102\u1166\u11c0"],["\ub13e","\u1102\u1166\u11c1"],["\ub13f","\u1102\u1166\u11c2"],["\ub140","\u1102\u1167"],["\ub141","\u1102\u1167\u11a8"],["\ub142","\u1102\u1167\u11a9"],["\ub143","\u1102\u1167\u11aa"],["\ub144","\u1102\u1167\u11ab"],["\ub145","\u1102\u1167\u11ac"],["\ub146","\u1102\u1167\u11ad"],["\ub147","\u1102\u1167\u11ae"],["\ub148","\u1102\u1167\u11af"],["\ub149","\u1102\u1167\u11b0"],["\ub14a","\u1102\u1167\u11b1"],["\ub14b","\u1102\u1167\u11b2"],["\ub14c","\u1102\u1167\u11b3"],["\ub14d","\u1102\u1167\u11b4"],["\ub14e","\u1102\u1167\u11b5"],["\ub14f","\u1102\u1167\u11b6"],["\ub150","\u1102\u1167\u11b7"],["\ub151","\u1102\u1167\u11b8"],["\ub152","\u1102\u1167\u11b9"],["\ub153","\u1102\u1167\u11ba"],["\ub154","\u1102\u1167\u11bb"],["\ub155","\u1102\u1167\u11bc"],["\ub156","\u1102\u1167\u11bd"],["\ub157","\u1102\u1167\u11be"],["\ub158","\u1102\u1167\u11bf"],["\ub159","\u1102\u1167\u11c0"],["\ub15a","\u1102\u1167\u11c1"],["\ub15b","\u1102\u1167\u11c2"],["\ub15c","\u1102\u1168"],["\ub15d","\u1102\u1168\u11a8"],["\ub15e","\u1102\u1168\u11a9"],["\ub15f","\u1102\u1168\u11aa"],["\ub160","\u1102\u1168\u11ab"],["\ub161","\u1102\u1168\u11ac"],["\ub162","\u1102\u1168\u11ad"],["\ub163","\u1102\u1168\u11ae"],["\ub164","\u1102\u1168\u11af"],["\ub165","\u1102\u1168\u11b0"],["\ub166","\u1102\u1168\u11b1"],["\ub167","\u1102\u1168\u11b2"],["\ub168","\u1102\u1168\u11b3"],["\ub169","\u1102\u1168\u11b4"],["\ub16a","\u1102\u1168\u11b5"],["\ub16b","\u1102\u1168\u11b6"],["\ub16c","\u1102\u1168\u11b7"],["\ub16d","\u1102\u1168\u11b8"],["\ub16e","\u1102\u1168\u11b9"],["\ub16f","\u1102\u1168\u11ba"],["\ub170","\u1102\u1168\u11bb"],["\ub171","\u1102\u1168\u11bc"],["\ub172","\u1102\u1168\u11bd"],["\ub173","\u1102\u1168\u11be"],["\ub174","\u1102\u1168\u11bf"],["\ub175","\u1102\u1168\u11c0"],["\ub176","\u1102\u1168\u11c1"],["\ub177","\u1102\u1168\u11c2"],["\ub178","\u1102\u1169"],["\ub179","\u1102\u1169\u11a8"],["\ub17a","\u1102\u1169\u11a9"],["\ub17b","\u1102\u1169\u11aa"],["\ub17c","\u1102\u1169\u11ab"],["\ub17d","\u1102\u1169\u11ac"],["\ub17e","\u1102\u1169\u11ad"],["\ub17f","\u1102\u1169\u11ae"],["\ub180","\u1102\u1169\u11af"],["\ub181","\u1102\u1169\u11b0"],["\ub182","\u1102\u1169\u11b1"],["\ub183","\u1102\u1169\u11b2"],["\ub184","\u1102\u1169\u11b3"],["\ub185","\u1102\u1169\u11b4"],["\ub186","\u1102\u1169\u11b5"],["\ub187","\u1102\u1169\u11b6"],["\ub188","\u1102\u1169\u11b7"],["\ub189","\u1102\u1169\u11b8"],["\ub18a","\u1102\u1169\u11b9"],["\ub18b","\u1102\u1169\u11ba"],["\ub18c","\u1102\u1169\u11bb"],["\ub18d","\u1102\u1169\u11bc"],["\ub18e","\u1102\u1169\u11bd"],["\ub18f","\u1102\u1169\u11be"],["\ub190","\u1102\u1169\u11bf"],["\ub191","\u1102\u1169\u11c0"],["\ub192","\u1102\u1169\u11c1"],["\ub193","\u1102\u1169\u11c2"],["\ub194","\u1102\u116a"],["\ub195","\u1102\u116a\u11a8"],["\ub196","\u1102\u116a\u11a9"],["\ub197","\u1102\u116a\u11aa"],["\ub198","\u1102\u116a\u11ab"],["\ub199","\u1102\u116a\u11ac"],["\ub19a","\u1102\u116a\u11ad"],["\ub19b","\u1102\u116a\u11ae"],["\ub19c","\u1102\u116a\u11af"],["\ub19d","\u1102\u116a\u11b0"],["\ub19e","\u1102\u116a\u11b1"],["\ub19f","\u1102\u116a\u11b2"],["\ub1a0","\u1102\u116a\u11b3"],["\ub1a1","\u1102\u116a\u11b4"],["\ub1a2","\u1102\u116a\u11b5"],["\ub1a3","\u1102\u116a\u11b6"],["\ub1a4","\u1102\u116a\u11b7"],["\ub1a5","\u1102\u116a\u11b8"],["\ub1a6","\u1102\u116a\u11b9"],["\ub1a7","\u1102\u116a\u11ba"],["\ub1a8","\u1102\u116a\u11bb"],["\ub1a9","\u1102\u116a\u11bc"],["\ub1aa","\u1102\u116a\u11bd"],["\ub1ab","\u1102\u116a\u11be"],["\ub1ac","\u1102\u116a\u11bf"],["\ub1ad","\u1102\u116a\u11c0"],["\ub1ae","\u1102\u116a\u11c1"],["\ub1af","\u1102\u116a\u11c2"],["\ub1b0","\u1102\u116b"],["\ub1b1","\u1102\u116b\u11a8"],["\ub1b2","\u1102\u116b\u11a9"],["\ub1b3","\u1102\u116b\u11aa"],["\ub1b4","\u1102\u116b\u11ab"],["\ub1b5","\u1102\u116b\u11ac"],["\ub1b6","\u1102\u116b\u11ad"],["\ub1b7","\u1102\u116b\u11ae"],["\ub1b8","\u1102\u116b\u11af"],["\ub1b9","\u1102\u116b\u11b0"],["\ub1ba","\u1102\u116b\u11b1"],["\ub1bb","\u1102\u116b\u11b2"],["\ub1bc","\u1102\u116b\u11b3"],["\ub1bd","\u1102\u116b\u11b4"],["\ub1be","\u1102\u116b\u11b5"],["\ub1bf","\u1102\u116b\u11b6"],["\ub1c0","\u1102\u116b\u11b7"],["\ub1c1","\u1102\u116b\u11b8"],["\ub1c2","\u1102\u116b\u11b9"],["\ub1c3","\u1102\u116b\u11ba"],["\ub1c4","\u1102\u116b\u11bb"],["\ub1c5","\u1102\u116b\u11bc"],["\ub1c6","\u1102\u116b\u11bd"],["\ub1c7","\u1102\u116b\u11be"],["\ub1c8","\u1102\u116b\u11bf"],["\ub1c9","\u1102\u116b\u11c0"],["\ub1ca","\u1102\u116b\u11c1"],["\ub1cb","\u1102\u116b\u11c2"],["\ub1cc","\u1102\u116c"],["\ub1cd","\u1102\u116c\u11a8"],["\ub1ce","\u1102\u116c\u11a9"],["\ub1cf","\u1102\u116c\u11aa"],["\ub1d0","\u1102\u116c\u11ab"],["\ub1d1","\u1102\u116c\u11ac"],["\ub1d2","\u1102\u116c\u11ad"],["\ub1d3","\u1102\u116c\u11ae"],["\ub1d4","\u1102\u116c\u11af"],["\ub1d5","\u1102\u116c\u11b0"],["\ub1d6","\u1102\u116c\u11b1"],["\ub1d7","\u1102\u116c\u11b2"],["\ub1d8","\u1102\u116c\u11b3"],["\ub1d9","\u1102\u116c\u11b4"],["\ub1da","\u1102\u116c\u11b5"],["\ub1db","\u1102\u116c\u11b6"],["\ub1dc","\u1102\u116c\u11b7"],["\ub1dd","\u1102\u116c\u11b8"],["\ub1de","\u1102\u116c\u11b9"],["\ub1df","\u1102\u116c\u11ba"],["\ub1e0","\u1102\u116c\u11bb"],["\ub1e1","\u1102\u116c\u11bc"],["\ub1e2","\u1102\u116c\u11bd"],["\ub1e3","\u1102\u116c\u11be"],["\ub1e4","\u1102\u116c\u11bf"],["\ub1e5","\u1102\u116c\u11c0"],["\ub1e6","\u1102\u116c\u11c1"],["\ub1e7","\u1102\u116c\u11c2"],["\ub1e8","\u1102\u116d"],["\ub1e9","\u1102\u116d\u11a8"],["\ub1ea","\u1102\u116d\u11a9"],["\ub1eb","\u1102\u116d\u11aa"],["\ub1ec","\u1102\u116d\u11ab"],["\ub1ed","\u1102\u116d\u11ac"],["\ub1ee","\u1102\u116d\u11ad"],["\ub1ef","\u1102\u116d\u11ae"],["\ub1f0","\u1102\u116d\u11af"],["\ub1f1","\u1102\u116d\u11b0"],["\ub1f2","\u1102\u116d\u11b1"],["\ub1f3","\u1102\u116d\u11b2"],["\ub1f4","\u1102\u116d\u11b3"],["\ub1f5","\u1102\u116d\u11b4"],["\ub1f6","\u1102\u116d\u11b5"],["\ub1f7","\u1102\u116d\u11b6"],["\ub1f8","\u1102\u116d\u11b7"],["\ub1f9","\u1102\u116d\u11b8"],["\ub1fa","\u1102\u116d\u11b9"],["\ub1fb","\u1102\u116d\u11ba"],["\ub1fc","\u1102\u116d\u11bb"],["\ub1fd","\u1102\u116d\u11bc"],["\ub1fe","\u1102\u116d\u11bd"],["\ub1ff","\u1102\u116d\u11be"],["\ub200","\u1102\u116d\u11bf"],["\ub201","\u1102\u116d\u11c0"],["\ub202","\u1102\u116d\u11c1"],["\ub203","\u1102\u116d\u11c2"],["\ub204","\u1102\u116e"],["\ub205","\u1102\u116e\u11a8"],["\ub206","\u1102\u116e\u11a9"],["\ub207","\u1102\u116e\u11aa"],["\ub208","\u1102\u116e\u11ab"],["\ub209","\u1102\u116e\u11ac"],["\ub20a","\u1102\u116e\u11ad"],["\ub20b","\u1102\u116e\u11ae"],["\ub20c","\u1102\u116e\u11af"],["\ub20d","\u1102\u116e\u11b0"],["\ub20e","\u1102\u116e\u11b1"],["\ub20f","\u1102\u116e\u11b2"],["\ub210","\u1102\u116e\u11b3"],["\ub211","\u1102\u116e\u11b4"],["\ub212","\u1102\u116e\u11b5"],["\ub213","\u1102\u116e\u11b6"],["\ub214","\u1102\u116e\u11b7"],["\ub215","\u1102\u116e\u11b8"],["\ub216","\u1102\u116e\u11b9"],["\ub217","\u1102\u116e\u11ba"],["\ub218","\u1102\u116e\u11bb"],["\ub219","\u1102\u116e\u11bc"],["\ub21a","\u1102\u116e\u11bd"],["\ub21b","\u1102\u116e\u11be"],["\ub21c","\u1102\u116e\u11bf"],["\ub21d","\u1102\u116e\u11c0"],["\ub21e","\u1102\u116e\u11c1"],["\ub21f","\u1102\u116e\u11c2"],["\ub220","\u1102\u116f"],["\ub221","\u1102\u116f\u11a8"],["\ub222","\u1102\u116f\u11a9"],["\ub223","\u1102\u116f\u11aa"],["\ub224","\u1102\u116f\u11ab"],["\ub225","\u1102\u116f\u11ac"],["\ub226","\u1102\u116f\u11ad"],["\ub227","\u1102\u116f\u11ae"],["\ub228","\u1102\u116f\u11af"],["\ub229","\u1102\u116f\u11b0"],["\ub22a","\u1102\u116f\u11b1"],["\ub22b","\u1102\u116f\u11b2"],["\ub22c","\u1102\u116f\u11b3"],["\ub22d","\u1102\u116f\u11b4"],["\ub22e","\u1102\u116f\u11b5"],["\ub22f","\u1102\u116f\u11b6"],["\ub230","\u1102\u116f\u11b7"],["\ub231","\u1102\u116f\u11b8"],["\ub232","\u1102\u116f\u11b9"],["\ub233","\u1102\u116f\u11ba"],["\ub234","\u1102\u116f\u11bb"],["\ub235","\u1102\u116f\u11bc"],["\ub236","\u1102\u116f\u11bd"],["\ub237","\u1102\u116f\u11be"],["\ub238","\u1102\u116f\u11bf"],["\ub239","\u1102\u116f\u11c0"],["\ub23a","\u1102\u116f\u11c1"],["\ub23b","\u1102\u116f\u11c2"],["\ub23c","\u1102\u1170"],["\ub23d","\u1102\u1170\u11a8"],["\ub23e","\u1102\u1170\u11a9"],["\ub23f","\u1102\u1170\u11aa"],["\ub240","\u1102\u1170\u11ab"],["\ub241","\u1102\u1170\u11ac"],["\ub242","\u1102\u1170\u11ad"],["\ub243","\u1102\u1170\u11ae"],["\ub244","\u1102\u1170\u11af"],["\ub245","\u1102\u1170\u11b0"],["\ub246","\u1102\u1170\u11b1"],["\ub247","\u1102\u1170\u11b2"],["\ub248","\u1102\u1170\u11b3"],["\ub249","\u1102\u1170\u11b4"],["\ub24a","\u1102\u1170\u11b5"],["\ub24b","\u1102\u1170\u11b6"],["\ub24c","\u1102\u1170\u11b7"],["\ub24d","\u1102\u1170\u11b8"],["\ub24e","\u1102\u1170\u11b9"],["\ub24f","\u1102\u1170\u11ba"],["\ub250","\u1102\u1170\u11bb"],["\ub251","\u1102\u1170\u11bc"],["\ub252","\u1102\u1170\u11bd"],["\ub253","\u1102\u1170\u11be"],["\ub254","\u1102\u1170\u11bf"],["\ub255","\u1102\u1170\u11c0"],["\ub256","\u1102\u1170\u11c1"],["\ub257","\u1102\u1170\u11c2"],["\ub258","\u1102\u1171"],["\ub259","\u1102\u1171\u11a8"],["\ub25a","\u1102\u1171\u11a9"],["\ub25b","\u1102\u1171\u11aa"],["\ub25c","\u1102\u1171\u11ab"],["\ub25d","\u1102\u1171\u11ac"],["\ub25e","\u1102\u1171\u11ad"],["\ub25f","\u1102\u1171\u11ae"],["\ub260","\u1102\u1171\u11af"],["\ub261","\u1102\u1171\u11b0"],["\ub262","\u1102\u1171\u11b1"],["\ub263","\u1102\u1171\u11b2"],["\ub264","\u1102\u1171\u11b3"],["\ub265","\u1102\u1171\u11b4"],["\ub266","\u1102\u1171\u11b5"],["\ub267","\u1102\u1171\u11b6"],["\ub268","\u1102\u1171\u11b7"],["\ub269","\u1102\u1171\u11b8"],["\ub26a","\u1102\u1171\u11b9"],["\ub26b","\u1102\u1171\u11ba"],["\ub26c","\u1102\u1171\u11bb"],["\ub26d","\u1102\u1171\u11bc"],["\ub26e","\u1102\u1171\u11bd"],["\ub26f","\u1102\u1171\u11be"],["\ub270","\u1102\u1171\u11bf"],["\ub271","\u1102\u1171\u11c0"],["\ub272","\u1102\u1171\u11c1"],["\ub273","\u1102\u1171\u11c2"],["\ub274","\u1102\u1172"],["\ub275","\u1102\u1172\u11a8"],["\ub276","\u1102\u1172\u11a9"],["\ub277","\u1102\u1172\u11aa"],["\ub278","\u1102\u1172\u11ab"],["\ub279","\u1102\u1172\u11ac"],["\ub27a","\u1102\u1172\u11ad"],["\ub27b","\u1102\u1172\u11ae"],["\ub27c","\u1102\u1172\u11af"],["\ub27d","\u1102\u1172\u11b0"],["\ub27e","\u1102\u1172\u11b1"],["\ub27f","\u1102\u1172\u11b2"],["\ub280","\u1102\u1172\u11b3"],["\ub281","\u1102\u1172\u11b4"],["\ub282","\u1102\u1172\u11b5"],["\ub283","\u1102\u1172\u11b6"],["\ub284","\u1102\u1172\u11b7"],["\ub285","\u1102\u1172\u11b8"],["\ub286","\u1102\u1172\u11b9"],["\ub287","\u1102\u1172\u11ba"],["\ub288","\u1102\u1172\u11bb"],["\ub289","\u1102\u1172\u11bc"],["\ub28a","\u1102\u1172\u11bd"],["\ub28b","\u1102\u1172\u11be"],["\ub28c","\u1102\u1172\u11bf"],["\ub28d","\u1102\u1172\u11c0"],["\ub28e","\u1102\u1172\u11c1"],["\ub28f","\u1102\u1172\u11c2"],["\ub290","\u1102\u1173"],["\ub291","\u1102\u1173\u11a8"],["\ub292","\u1102\u1173\u11a9"],["\ub293","\u1102\u1173\u11aa"],["\ub294","\u1102\u1173\u11ab"],["\ub295","\u1102\u1173\u11ac"],["\ub296","\u1102\u1173\u11ad"],["\ub297","\u1102\u1173\u11ae"],["\ub298","\u1102\u1173\u11af"],["\ub299","\u1102\u1173\u11b0"],["\ub29a","\u1102\u1173\u11b1"],["\ub29b","\u1102\u1173\u11b2"],["\ub29c","\u1102\u1173\u11b3"],["\ub29d","\u1102\u1173\u11b4"],["\ub29e","\u1102\u1173\u11b5"],["\ub29f","\u1102\u1173\u11b6"],["\ub2a0","\u1102\u1173\u11b7"],["\ub2a1","\u1102\u1173\u11b8"],["\ub2a2","\u1102\u1173\u11b9"],["\ub2a3","\u1102\u1173\u11ba"],["\ub2a4","\u1102\u1173\u11bb"],["\ub2a5","\u1102\u1173\u11bc"],["\ub2a6","\u1102\u1173\u11bd"],["\ub2a7","\u1102\u1173\u11be"],["\ub2a8","\u1102\u1173\u11bf"],["\ub2a9","\u1102\u1173\u11c0"],["\ub2aa","\u1102\u1173\u11c1"],["\ub2ab","\u1102\u1173\u11c2"],["\ub2ac","\u1102\u1174"],["\ub2ad","\u1102\u1174\u11a8"],["\ub2ae","\u1102\u1174\u11a9"],["\ub2af","\u1102\u1174\u11aa"],["\ub2b0","\u1102\u1174\u11ab"],["\ub2b1","\u1102\u1174\u11ac"],["\ub2b2","\u1102\u1174\u11ad"],["\ub2b3","\u1102\u1174\u11ae"],["\ub2b4","\u1102\u1174\u11af"],["\ub2b5","\u1102\u1174\u11b0"],["\ub2b6","\u1102\u1174\u11b1"],["\ub2b7","\u1102\u1174\u11b2"],["\ub2b8","\u1102\u1174\u11b3"],["\ub2b9","\u1102\u1174\u11b4"],["\ub2ba","\u1102\u1174\u11b5"],["\ub2bb","\u1102\u1174\u11b6"],["\ub2bc","\u1102\u1174\u11b7"],["\ub2bd","\u1102\u1174\u11b8"],["\ub2be","\u1102\u1174\u11b9"],["\ub2bf","\u1102\u1174\u11ba"],["\ub2c0","\u1102\u1174\u11bb"],["\ub2c1","\u1102\u1174\u11bc"],["\ub2c2","\u1102\u1174\u11bd"],["\ub2c3","\u1102\u1174\u11be"],["\ub2c4","\u1102\u1174\u11bf"],["\ub2c5","\u1102\u1174\u11c0"],["\ub2c6","\u1102\u1174\u11c1"],["\ub2c7","\u1102\u1174\u11c2"],["\ub2c8","\u1102\u1175"],["\ub2c9","\u1102\u1175\u11a8"],["\ub2ca","\u1102\u1175\u11a9"],["\ub2cb","\u1102\u1175\u11aa"],["\ub2cc","\u1102\u1175\u11ab"],["\ub2cd","\u1102\u1175\u11ac"],["\ub2ce","\u1102\u1175\u11ad"],["\ub2cf","\u1102\u1175\u11ae"],["\ub2d0","\u1102\u1175\u11af"],["\ub2d1","\u1102\u1175\u11b0"],["\ub2d2","\u1102\u1175\u11b1"],["\ub2d3","\u1102\u1175\u11b2"],["\ub2d4","\u1102\u1175\u11b3"],["\ub2d5","\u1102\u1175\u11b4"],["\ub2d6","\u1102\u1175\u11b5"],["\ub2d7","\u1102\u1175\u11b6"],["\ub2d8","\u1102\u1175\u11b7"],["\ub2d9","\u1102\u1175\u11b8"],["\ub2da","\u1102\u1175\u11b9"],["\ub2db","\u1102\u1175\u11ba"],["\ub2dc","\u1102\u1175\u11bb"],["\ub2dd","\u1102\u1175\u11bc"],["\ub2de","\u1102\u1175\u11bd"],["\ub2df","\u1102\u1175\u11be"],["\ub2e0","\u1102\u1175\u11bf"],["\ub2e1","\u1102\u1175\u11c0"],["\ub2e2","\u1102\u1175\u11c1"],["\ub2e3","\u1102\u1175\u11c2"],["\ub2e4","\u1103\u1161"],["\ub2e5","\u1103\u1161\u11a8"],["\ub2e6","\u1103\u1161\u11a9"],["\ub2e7","\u1103\u1161\u11aa"],["\ub2e8","\u1103\u1161\u11ab"],["\ub2e9","\u1103\u1161\u11ac"],["\ub2ea","\u1103\u1161\u11ad"],["\ub2eb","\u1103\u1161\u11ae"],["\ub2ec","\u1103\u1161\u11af"],["\ub2ed","\u1103\u1161\u11b0"],["\ub2ee","\u1103\u1161\u11b1"],["\ub2ef","\u1103\u1161\u11b2"],["\ub2f0","\u1103\u1161\u11b3"],["\ub2f1","\u1103\u1161\u11b4"],["\ub2f2","\u1103\u1161\u11b5"],["\ub2f3","\u1103\u1161\u11b6"],["\ub2f4","\u1103\u1161\u11b7"],["\ub2f5","\u1103\u1161\u11b8"],["\ub2f6","\u1103\u1161\u11b9"],["\ub2f7","\u1103\u1161\u11ba"],["\ub2f8","\u1103\u1161\u11bb"],["\ub2f9","\u1103\u1161\u11bc"],["\ub2fa","\u1103\u1161\u11bd"],["\ub2fb","\u1103\u1161\u11be"],["\ub2fc","\u1103\u1161\u11bf"],["\ub2fd","\u1103\u1161\u11c0"],["\ub2fe","\u1103\u1161\u11c1"],["\ub2ff","\u1103\u1161\u11c2"],["\ub300","\u1103\u1162"],["\ub301","\u1103\u1162\u11a8"],["\ub302","\u1103\u1162\u11a9"],["\ub303","\u1103\u1162\u11aa"],["\ub304","\u1103\u1162\u11ab"],["\ub305","\u1103\u1162\u11ac"],["\ub306","\u1103\u1162\u11ad"],["\ub307","\u1103\u1162\u11ae"],["\ub308","\u1103\u1162\u11af"],["\ub309","\u1103\u1162\u11b0"],["\ub30a","\u1103\u1162\u11b1"],["\ub30b","\u1103\u1162\u11b2"],["\ub30c","\u1103\u1162\u11b3"],["\ub30d","\u1103\u1162\u11b4"],["\ub30e","\u1103\u1162\u11b5"],["\ub30f","\u1103\u1162\u11b6"],["\ub310","\u1103\u1162\u11b7"],["\ub311","\u1103\u1162\u11b8"],["\ub312","\u1103\u1162\u11b9"],["\ub313","\u1103\u1162\u11ba"],["\ub314","\u1103\u1162\u11bb"],["\ub315","\u1103\u1162\u11bc"],["\ub316","\u1103\u1162\u11bd"],["\ub317","\u1103\u1162\u11be"],["\ub318","\u1103\u1162\u11bf"],["\ub319","\u1103\u1162\u11c0"],["\ub31a","\u1103\u1162\u11c1"],["\ub31b","\u1103\u1162\u11c2"],["\ub31c","\u1103\u1163"],["\ub31d","\u1103\u1163\u11a8"],["\ub31e","\u1103\u1163\u11a9"],["\ub31f","\u1103\u1163\u11aa"],["\ub320","\u1103\u1163\u11ab"],["\ub321","\u1103\u1163\u11ac"],["\ub322","\u1103\u1163\u11ad"],["\ub323","\u1103\u1163\u11ae"],["\ub324","\u1103\u1163\u11af"],["\ub325","\u1103\u1163\u11b0"],["\ub326","\u1103\u1163\u11b1"],["\ub327","\u1103\u1163\u11b2"],["\ub328","\u1103\u1163\u11b3"],["\ub329","\u1103\u1163\u11b4"],["\ub32a","\u1103\u1163\u11b5"],["\ub32b","\u1103\u1163\u11b6"],["\ub32c","\u1103\u1163\u11b7"],["\ub32d","\u1103\u1163\u11b8"],["\ub32e","\u1103\u1163\u11b9"],["\ub32f","\u1103\u1163\u11ba"],["\ub330","\u1103\u1163\u11bb"],["\ub331","\u1103\u1163\u11bc"],["\ub332","\u1103\u1163\u11bd"],["\ub333","\u1103\u1163\u11be"],["\ub334","\u1103\u1163\u11bf"],["\ub335","\u1103\u1163\u11c0"],["\ub336","\u1103\u1163\u11c1"],["\ub337","\u1103\u1163\u11c2"],["\ub338","\u1103\u1164"],["\ub339","\u1103\u1164\u11a8"],["\ub33a","\u1103\u1164\u11a9"],["\ub33b","\u1103\u1164\u11aa"],["\ub33c","\u1103\u1164\u11ab"],["\ub33d","\u1103\u1164\u11ac"],["\ub33e","\u1103\u1164\u11ad"],["\ub33f","\u1103\u1164\u11ae"],["\ub340","\u1103\u1164\u11af"],["\ub341","\u1103\u1164\u11b0"],["\ub342","\u1103\u1164\u11b1"],["\ub343","\u1103\u1164\u11b2"],["\ub344","\u1103\u1164\u11b3"],["\ub345","\u1103\u1164\u11b4"],["\ub346","\u1103\u1164\u11b5"],["\ub347","\u1103\u1164\u11b6"],["\ub348","\u1103\u1164\u11b7"],["\ub349","\u1103\u1164\u11b8"],["\ub34a","\u1103\u1164\u11b9"],["\ub34b","\u1103\u1164\u11ba"],["\ub34c","\u1103\u1164\u11bb"],["\ub34d","\u1103\u1164\u11bc"],["\ub34e","\u1103\u1164\u11bd"],["\ub34f","\u1103\u1164\u11be"],["\ub350","\u1103\u1164\u11bf"],["\ub351","\u1103\u1164\u11c0"],["\ub352","\u1103\u1164\u11c1"],["\ub353","\u1103\u1164\u11c2"],["\ub354","\u1103\u1165"],["\ub355","\u1103\u1165\u11a8"],["\ub356","\u1103\u1165\u11a9"],["\ub357","\u1103\u1165\u11aa"],["\ub358","\u1103\u1165\u11ab"],["\ub359","\u1103\u1165\u11ac"],["\ub35a","\u1103\u1165\u11ad"],["\ub35b","\u1103\u1165\u11ae"],["\ub35c","\u1103\u1165\u11af"],["\ub35d","\u1103\u1165\u11b0"],["\ub35e","\u1103\u1165\u11b1"],["\ub35f","\u1103\u1165\u11b2"],["\ub360","\u1103\u1165\u11b3"],["\ub361","\u1103\u1165\u11b4"],["\ub362","\u1103\u1165\u11b5"],["\ub363","\u1103\u1165\u11b6"],["\ub364","\u1103\u1165\u11b7"],["\ub365","\u1103\u1165\u11b8"],["\ub366","\u1103\u1165\u11b9"],["\ub367","\u1103\u1165\u11ba"],["\ub368","\u1103\u1165\u11bb"],["\ub369","\u1103\u1165\u11bc"],["\ub36a","\u1103\u1165\u11bd"],["\ub36b","\u1103\u1165\u11be"],["\ub36c","\u1103\u1165\u11bf"],["\ub36d","\u1103\u1165\u11c0"],["\ub36e","\u1103\u1165\u11c1"],["\ub36f","\u1103\u1165\u11c2"],["\ub370","\u1103\u1166"],["\ub371","\u1103\u1166\u11a8"],["\ub372","\u1103\u1166\u11a9"],["\ub373","\u1103\u1166\u11aa"],["\ub374","\u1103\u1166\u11ab"],["\ub375","\u1103\u1166\u11ac"],["\ub376","\u1103\u1166\u11ad"],["\ub377","\u1103\u1166\u11ae"],["\ub378","\u1103\u1166\u11af"],["\ub379","\u1103\u1166\u11b0"],["\ub37a","\u1103\u1166\u11b1"],["\ub37b","\u1103\u1166\u11b2"],["\ub37c","\u1103\u1166\u11b3"],["\ub37d","\u1103\u1166\u11b4"],["\ub37e","\u1103\u1166\u11b5"],["\ub37f","\u1103\u1166\u11b6"],["\ub380","\u1103\u1166\u11b7"],["\ub381","\u1103\u1166\u11b8"],["\ub382","\u1103\u1166\u11b9"],["\ub383","\u1103\u1166\u11ba"],["\ub384","\u1103\u1166\u11bb"],["\ub385","\u1103\u1166\u11bc"],["\ub386","\u1103\u1166\u11bd"],["\ub387","\u1103\u1166\u11be"],["\ub388","\u1103\u1166\u11bf"],["\ub389","\u1103\u1166\u11c0"],["\ub38a","\u1103\u1166\u11c1"],["\ub38b","\u1103\u1166\u11c2"],["\ub38c","\u1103\u1167"],["\ub38d","\u1103\u1167\u11a8"],["\ub38e","\u1103\u1167\u11a9"],["\ub38f","\u1103\u1167\u11aa"],["\ub390","\u1103\u1167\u11ab"],["\ub391","\u1103\u1167\u11ac"],["\ub392","\u1103\u1167\u11ad"],["\ub393","\u1103\u1167\u11ae"],["\ub394","\u1103\u1167\u11af"],["\ub395","\u1103\u1167\u11b0"],["\ub396","\u1103\u1167\u11b1"],["\ub397","\u1103\u1167\u11b2"],["\ub398","\u1103\u1167\u11b3"],["\ub399","\u1103\u1167\u11b4"],["\ub39a","\u1103\u1167\u11b5"],["\ub39b","\u1103\u1167\u11b6"],["\ub39c","\u1103\u1167\u11b7"],["\ub39d","\u1103\u1167\u11b8"],["\ub39e","\u1103\u1167\u11b9"],["\ub39f","\u1103\u1167\u11ba"],["\ub3a0","\u1103\u1167\u11bb"],["\ub3a1","\u1103\u1167\u11bc"],["\ub3a2","\u1103\u1167\u11bd"],["\ub3a3","\u1103\u1167\u11be"],["\ub3a4","\u1103\u1167\u11bf"],["\ub3a5","\u1103\u1167\u11c0"],["\ub3a6","\u1103\u1167\u11c1"],["\ub3a7","\u1103\u1167\u11c2"],["\ub3a8","\u1103\u1168"],["\ub3a9","\u1103\u1168\u11a8"],["\ub3aa","\u1103\u1168\u11a9"],["\ub3ab","\u1103\u1168\u11aa"],["\ub3ac","\u1103\u1168\u11ab"],["\ub3ad","\u1103\u1168\u11ac"],["\ub3ae","\u1103\u1168\u11ad"],["\ub3af","\u1103\u1168\u11ae"],["\ub3b0","\u1103\u1168\u11af"],["\ub3b1","\u1103\u1168\u11b0"],["\ub3b2","\u1103\u1168\u11b1"],["\ub3b3","\u1103\u1168\u11b2"],["\ub3b4","\u1103\u1168\u11b3"],["\ub3b5","\u1103\u1168\u11b4"],["\ub3b6","\u1103\u1168\u11b5"],["\ub3b7","\u1103\u1168\u11b6"],["\ub3b8","\u1103\u1168\u11b7"],["\ub3b9","\u1103\u1168\u11b8"],["\ub3ba","\u1103\u1168\u11b9"],["\ub3bb","\u1103\u1168\u11ba"],["\ub3bc","\u1103\u1168\u11bb"],["\ub3bd","\u1103\u1168\u11bc"],["\ub3be","\u1103\u1168\u11bd"],["\ub3bf","\u1103\u1168\u11be"],["\ub3c0","\u1103\u1168\u11bf"],["\ub3c1","\u1103\u1168\u11c0"],["\ub3c2","\u1103\u1168\u11c1"],["\ub3c3","\u1103\u1168\u11c2"],["\ub3c4","\u1103\u1169"],["\ub3c5","\u1103\u1169\u11a8"],["\ub3c6","\u1103\u1169\u11a9"],["\ub3c7","\u1103\u1169\u11aa"],["\ub3c8","\u1103\u1169\u11ab"],["\ub3c9","\u1103\u1169\u11ac"],["\ub3ca","\u1103\u1169\u11ad"],["\ub3cb","\u1103\u1169\u11ae"],["\ub3cc","\u1103\u1169\u11af"],["\ub3cd","\u1103\u1169\u11b0"],["\ub3ce","\u1103\u1169\u11b1"],["\ub3cf","\u1103\u1169\u11b2"],["\ub3d0","\u1103\u1169\u11b3"],["\ub3d1","\u1103\u1169\u11b4"],["\ub3d2","\u1103\u1169\u11b5"],["\ub3d3","\u1103\u1169\u11b6"],["\ub3d4","\u1103\u1169\u11b7"],["\ub3d5","\u1103\u1169\u11b8"],["\ub3d6","\u1103\u1169\u11b9"],["\ub3d7","\u1103\u1169\u11ba"],["\ub3d8","\u1103\u1169\u11bb"],["\ub3d9","\u1103\u1169\u11bc"],["\ub3da","\u1103\u1169\u11bd"],["\ub3db","\u1103\u1169\u11be"],["\ub3dc","\u1103\u1169\u11bf"],["\ub3dd","\u1103\u1169\u11c0"],["\ub3de","\u1103\u1169\u11c1"],["\ub3df","\u1103\u1169\u11c2"],["\ub3e0","\u1103\u116a"],["\ub3e1","\u1103\u116a\u11a8"],["\ub3e2","\u1103\u116a\u11a9"],["\ub3e3","\u1103\u116a\u11aa"],["\ub3e4","\u1103\u116a\u11ab"],["\ub3e5","\u1103\u116a\u11ac"],["\ub3e6","\u1103\u116a\u11ad"],["\ub3e7","\u1103\u116a\u11ae"],["\ub3e8","\u1103\u116a\u11af"],["\ub3e9","\u1103\u116a\u11b0"],["\ub3ea","\u1103\u116a\u11b1"],["\ub3eb","\u1103\u116a\u11b2"],["\ub3ec","\u1103\u116a\u11b3"],["\ub3ed","\u1103\u116a\u11b4"],["\ub3ee","\u1103\u116a\u11b5"],["\ub3ef","\u1103\u116a\u11b6"],["\ub3f0","\u1103\u116a\u11b7"],["\ub3f1","\u1103\u116a\u11b8"],["\ub3f2","\u1103\u116a\u11b9"],["\ub3f3","\u1103\u116a\u11ba"],["\ub3f4","\u1103\u116a\u11bb"],["\ub3f5","\u1103\u116a\u11bc"],["\ub3f6","\u1103\u116a\u11bd"],["\ub3f7","\u1103\u116a\u11be"],["\ub3f8","\u1103\u116a\u11bf"],["\ub3f9","\u1103\u116a\u11c0"],["\ub3fa","\u1103\u116a\u11c1"],["\ub3fb","\u1103\u116a\u11c2"],["\ub3fc","\u1103\u116b"],["\ub3fd","\u1103\u116b\u11a8"],["\ub3fe","\u1103\u116b\u11a9"],["\ub3ff","\u1103\u116b\u11aa"],["\ub400","\u1103\u116b\u11ab"],["\ub401","\u1103\u116b\u11ac"],["\ub402","\u1103\u116b\u11ad"],["\ub403","\u1103\u116b\u11ae"],["\ub404","\u1103\u116b\u11af"],["\ub405","\u1103\u116b\u11b0"],["\ub406","\u1103\u116b\u11b1"],["\ub407","\u1103\u116b\u11b2"],["\ub408","\u1103\u116b\u11b3"],["\ub409","\u1103\u116b\u11b4"],["\ub40a","\u1103\u116b\u11b5"],["\ub40b","\u1103\u116b\u11b6"],["\ub40c","\u1103\u116b\u11b7"],["\ub40d","\u1103\u116b\u11b8"],["\ub40e","\u1103\u116b\u11b9"],["\ub40f","\u1103\u116b\u11ba"],["\ub410","\u1103\u116b\u11bb"],["\ub411","\u1103\u116b\u11bc"],["\ub412","\u1103\u116b\u11bd"],["\ub413","\u1103\u116b\u11be"],["\ub414","\u1103\u116b\u11bf"],["\ub415","\u1103\u116b\u11c0"],["\ub416","\u1103\u116b\u11c1"],["\ub417","\u1103\u116b\u11c2"],["\ub418","\u1103\u116c"],["\ub419","\u1103\u116c\u11a8"],["\ub41a","\u1103\u116c\u11a9"],["\ub41b","\u1103\u116c\u11aa"],["\ub41c","\u1103\u116c\u11ab"],["\ub41d","\u1103\u116c\u11ac"],["\ub41e","\u1103\u116c\u11ad"],["\ub41f","\u1103\u116c\u11ae"],["\ub420","\u1103\u116c\u11af"],["\ub421","\u1103\u116c\u11b0"],["\ub422","\u1103\u116c\u11b1"],["\ub423","\u1103\u116c\u11b2"],["\ub424","\u1103\u116c\u11b3"],["\ub425","\u1103\u116c\u11b4"],["\ub426","\u1103\u116c\u11b5"],["\ub427","\u1103\u116c\u11b6"],["\ub428","\u1103\u116c\u11b7"],["\ub429","\u1103\u116c\u11b8"],["\ub42a","\u1103\u116c\u11b9"],["\ub42b","\u1103\u116c\u11ba"],["\ub42c","\u1103\u116c\u11bb"],["\ub42d","\u1103\u116c\u11bc"],["\ub42e","\u1103\u116c\u11bd"],["\ub42f","\u1103\u116c\u11be"],["\ub430","\u1103\u116c\u11bf"],["\ub431","\u1103\u116c\u11c0"],["\ub432","\u1103\u116c\u11c1"],["\ub433","\u1103\u116c\u11c2"],["\ub434","\u1103\u116d"],["\ub435","\u1103\u116d\u11a8"],["\ub436","\u1103\u116d\u11a9"],["\ub437","\u1103\u116d\u11aa"],["\ub438","\u1103\u116d\u11ab"],["\ub439","\u1103\u116d\u11ac"],["\ub43a","\u1103\u116d\u11ad"],["\ub43b","\u1103\u116d\u11ae"],["\ub43c","\u1103\u116d\u11af"],["\ub43d","\u1103\u116d\u11b0"],["\ub43e","\u1103\u116d\u11b1"],["\ub43f","\u1103\u116d\u11b2"],["\ub440","\u1103\u116d\u11b3"],["\ub441","\u1103\u116d\u11b4"],["\ub442","\u1103\u116d\u11b5"],["\ub443","\u1103\u116d\u11b6"],["\ub444","\u1103\u116d\u11b7"],["\ub445","\u1103\u116d\u11b8"],["\ub446","\u1103\u116d\u11b9"],["\ub447","\u1103\u116d\u11ba"],["\ub448","\u1103\u116d\u11bb"],["\ub449","\u1103\u116d\u11bc"],["\ub44a","\u1103\u116d\u11bd"],["\ub44b","\u1103\u116d\u11be"],["\ub44c","\u1103\u116d\u11bf"],["\ub44d","\u1103\u116d\u11c0"],["\ub44e","\u1103\u116d\u11c1"],["\ub44f","\u1103\u116d\u11c2"],["\ub450","\u1103\u116e"],["\ub451","\u1103\u116e\u11a8"],["\ub452","\u1103\u116e\u11a9"],["\ub453","\u1103\u116e\u11aa"],["\ub454","\u1103\u116e\u11ab"],["\ub455","\u1103\u116e\u11ac"],["\ub456","\u1103\u116e\u11ad"],["\ub457","\u1103\u116e\u11ae"],["\ub458","\u1103\u116e\u11af"],["\ub459","\u1103\u116e\u11b0"],["\ub45a","\u1103\u116e\u11b1"],["\ub45b","\u1103\u116e\u11b2"],["\ub45c","\u1103\u116e\u11b3"],["\ub45d","\u1103\u116e\u11b4"],["\ub45e","\u1103\u116e\u11b5"],["\ub45f","\u1103\u116e\u11b6"],["\ub460","\u1103\u116e\u11b7"],["\ub461","\u1103\u116e\u11b8"],["\ub462","\u1103\u116e\u11b9"],["\ub463","\u1103\u116e\u11ba"],["\ub464","\u1103\u116e\u11bb"],["\ub465","\u1103\u116e\u11bc"],["\ub466","\u1103\u116e\u11bd"],["\ub467","\u1103\u116e\u11be"],["\ub468","\u1103\u116e\u11bf"],["\ub469","\u1103\u116e\u11c0"],["\ub46a","\u1103\u116e\u11c1"],["\ub46b","\u1103\u116e\u11c2"],["\ub46c","\u1103\u116f"],["\ub46d","\u1103\u116f\u11a8"],["\ub46e","\u1103\u116f\u11a9"],["\ub46f","\u1103\u116f\u11aa"],["\ub470","\u1103\u116f\u11ab"],["\ub471","\u1103\u116f\u11ac"],["\ub472","\u1103\u116f\u11ad"],["\ub473","\u1103\u116f\u11ae"],["\ub474","\u1103\u116f\u11af"],["\ub475","\u1103\u116f\u11b0"],["\ub476","\u1103\u116f\u11b1"],["\ub477","\u1103\u116f\u11b2"],["\ub478","\u1103\u116f\u11b3"],["\ub479","\u1103\u116f\u11b4"],["\ub47a","\u1103\u116f\u11b5"],["\ub47b","\u1103\u116f\u11b6"],["\ub47c","\u1103\u116f\u11b7"],["\ub47d","\u1103\u116f\u11b8"],["\ub47e","\u1103\u116f\u11b9"],["\ub47f","\u1103\u116f\u11ba"],["\ub480","\u1103\u116f\u11bb"],["\ub481","\u1103\u116f\u11bc"],["\ub482","\u1103\u116f\u11bd"],["\ub483","\u1103\u116f\u11be"],["\ub484","\u1103\u116f\u11bf"],["\ub485","\u1103\u116f\u11c0"],["\ub486","\u1103\u116f\u11c1"],["\ub487","\u1103\u116f\u11c2"],["\ub488","\u1103\u1170"],["\ub489","\u1103\u1170\u11a8"],["\ub48a","\u1103\u1170\u11a9"],["\ub48b","\u1103\u1170\u11aa"],["\ub48c","\u1103\u1170\u11ab"],["\ub48d","\u1103\u1170\u11ac"],["\ub48e","\u1103\u1170\u11ad"],["\ub48f","\u1103\u1170\u11ae"],["\ub490","\u1103\u1170\u11af"],["\ub491","\u1103\u1170\u11b0"],["\ub492","\u1103\u1170\u11b1"],["\ub493","\u1103\u1170\u11b2"],["\ub494","\u1103\u1170\u11b3"],["\ub495","\u1103\u1170\u11b4"],["\ub496","\u1103\u1170\u11b5"],["\ub497","\u1103\u1170\u11b6"],["\ub498","\u1103\u1170\u11b7"],["\ub499","\u1103\u1170\u11b8"],["\ub49a","\u1103\u1170\u11b9"],["\ub49b","\u1103\u1170\u11ba"],["\ub49c","\u1103\u1170\u11bb"],["\ub49d","\u1103\u1170\u11bc"],["\ub49e","\u1103\u1170\u11bd"],["\ub49f","\u1103\u1170\u11be"],["\ub4a0","\u1103\u1170\u11bf"],["\ub4a1","\u1103\u1170\u11c0"],["\ub4a2","\u1103\u1170\u11c1"],["\ub4a3","\u1103\u1170\u11c2"],["\ub4a4","\u1103\u1171"],["\ub4a5","\u1103\u1171\u11a8"],["\ub4a6","\u1103\u1171\u11a9"],["\ub4a7","\u1103\u1171\u11aa"],["\ub4a8","\u1103\u1171\u11ab"],["\ub4a9","\u1103\u1171\u11ac"],["\ub4aa","\u1103\u1171\u11ad"],["\ub4ab","\u1103\u1171\u11ae"],["\ub4ac","\u1103\u1171\u11af"],["\ub4ad","\u1103\u1171\u11b0"],["\ub4ae","\u1103\u1171\u11b1"],["\ub4af","\u1103\u1171\u11b2"],["\ub4b0","\u1103\u1171\u11b3"],["\ub4b1","\u1103\u1171\u11b4"],["\ub4b2","\u1103\u1171\u11b5"],["\ub4b3","\u1103\u1171\u11b6"],["\ub4b4","\u1103\u1171\u11b7"],["\ub4b5","\u1103\u1171\u11b8"],["\ub4b6","\u1103\u1171\u11b9"],["\ub4b7","\u1103\u1171\u11ba"],["\ub4b8","\u1103\u1171\u11bb"],["\ub4b9","\u1103\u1171\u11bc"],["\ub4ba","\u1103\u1171\u11bd"],["\ub4bb","\u1103\u1171\u11be"],["\ub4bc","\u1103\u1171\u11bf"],["\ub4bd","\u1103\u1171\u11c0"],["\ub4be","\u1103\u1171\u11c1"],["\ub4bf","\u1103\u1171\u11c2"],["\ub4c0","\u1103\u1172"],["\ub4c1","\u1103\u1172\u11a8"],["\ub4c2","\u1103\u1172\u11a9"],["\ub4c3","\u1103\u1172\u11aa"],["\ub4c4","\u1103\u1172\u11ab"],["\ub4c5","\u1103\u1172\u11ac"],["\ub4c6","\u1103\u1172\u11ad"],["\ub4c7","\u1103\u1172\u11ae"],["\ub4c8","\u1103\u1172\u11af"],["\ub4c9","\u1103\u1172\u11b0"],["\ub4ca","\u1103\u1172\u11b1"],["\ub4cb","\u1103\u1172\u11b2"],["\ub4cc","\u1103\u1172\u11b3"],["\ub4cd","\u1103\u1172\u11b4"],["\ub4ce","\u1103\u1172\u11b5"],["\ub4cf","\u1103\u1172\u11b6"],["\ub4d0","\u1103\u1172\u11b7"],["\ub4d1","\u1103\u1172\u11b8"],["\ub4d2","\u1103\u1172\u11b9"],["\ub4d3","\u1103\u1172\u11ba"],["\ub4d4","\u1103\u1172\u11bb"],["\ub4d5","\u1103\u1172\u11bc"],["\ub4d6","\u1103\u1172\u11bd"],["\ub4d7","\u1103\u1172\u11be"],["\ub4d8","\u1103\u1172\u11bf"],["\ub4d9","\u1103\u1172\u11c0"],["\ub4da","\u1103\u1172\u11c1"],["\ub4db","\u1103\u1172\u11c2"],["\ub4dc","\u1103\u1173"],["\ub4dd","\u1103\u1173\u11a8"],["\ub4de","\u1103\u1173\u11a9"],["\ub4df","\u1103\u1173\u11aa"],["\ub4e0","\u1103\u1173\u11ab"],["\ub4e1","\u1103\u1173\u11ac"],["\ub4e2","\u1103\u1173\u11ad"],["\ub4e3","\u1103\u1173\u11ae"],["\ub4e4","\u1103\u1173\u11af"],["\ub4e5","\u1103\u1173\u11b0"],["\ub4e6","\u1103\u1173\u11b1"],["\ub4e7","\u1103\u1173\u11b2"],["\ub4e8","\u1103\u1173\u11b3"],["\ub4e9","\u1103\u1173\u11b4"],["\ub4ea","\u1103\u1173\u11b5"],["\ub4eb","\u1103\u1173\u11b6"],["\ub4ec","\u1103\u1173\u11b7"],["\ub4ed","\u1103\u1173\u11b8"],["\ub4ee","\u1103\u1173\u11b9"],["\ub4ef","\u1103\u1173\u11ba"],["\ub4f0","\u1103\u1173\u11bb"],["\ub4f1","\u1103\u1173\u11bc"],["\ub4f2","\u1103\u1173\u11bd"],["\ub4f3","\u1103\u1173\u11be"],["\ub4f4","\u1103\u1173\u11bf"],["\ub4f5","\u1103\u1173\u11c0"],["\ub4f6","\u1103\u1173\u11c1"],["\ub4f7","\u1103\u1173\u11c2"],["\ub4f8","\u1103\u1174"],["\ub4f9","\u1103\u1174\u11a8"],["\ub4fa","\u1103\u1174\u11a9"],["\ub4fb","\u1103\u1174\u11aa"],["\ub4fc","\u1103\u1174\u11ab"],["\ub4fd","\u1103\u1174\u11ac"],["\ub4fe","\u1103\u1174\u11ad"],["\ub4ff","\u1103\u1174\u11ae"],["\ub500","\u1103\u1174\u11af"],["\ub501","\u1103\u1174\u11b0"],["\ub502","\u1103\u1174\u11b1"],["\ub503","\u1103\u1174\u11b2"],["\ub504","\u1103\u1174\u11b3"],["\ub505","\u1103\u1174\u11b4"],["\ub506","\u1103\u1174\u11b5"],["\ub507","\u1103\u1174\u11b6"],["\ub508","\u1103\u1174\u11b7"],["\ub509","\u1103\u1174\u11b8"],["\ub50a","\u1103\u1174\u11b9"],["\ub50b","\u1103\u1174\u11ba"],["\ub50c","\u1103\u1174\u11bb"],["\ub50d","\u1103\u1174\u11bc"],["\ub50e","\u1103\u1174\u11bd"],["\ub50f","\u1103\u1174\u11be"],["\ub510","\u1103\u1174\u11bf"],["\ub511","\u1103\u1174\u11c0"],["\ub512","\u1103\u1174\u11c1"],["\ub513","\u1103\u1174\u11c2"],["\ub514","\u1103\u1175"],["\ub515","\u1103\u1175\u11a8"],["\ub516","\u1103\u1175\u11a9"],["\ub517","\u1103\u1175\u11aa"],["\ub518","\u1103\u1175\u11ab"],["\ub519","\u1103\u1175\u11ac"],["\ub51a","\u1103\u1175\u11ad"],["\ub51b","\u1103\u1175\u11ae"],["\ub51c","\u1103\u1175\u11af"],["\ub51d","\u1103\u1175\u11b0"],["\ub51e","\u1103\u1175\u11b1"],["\ub51f","\u1103\u1175\u11b2"],["\ub520","\u1103\u1175\u11b3"],["\ub521","\u1103\u1175\u11b4"],["\ub522","\u1103\u1175\u11b5"],["\ub523","\u1103\u1175\u11b6"],["\ub524","\u1103\u1175\u11b7"],["\ub525","\u1103\u1175\u11b8"],["\ub526","\u1103\u1175\u11b9"],["\ub527","\u1103\u1175\u11ba"],["\ub528","\u1103\u1175\u11bb"],["\ub529","\u1103\u1175\u11bc"],["\ub52a","\u1103\u1175\u11bd"],["\ub52b","\u1103\u1175\u11be"],["\ub52c","\u1103\u1175\u11bf"],["\ub52d","\u1103\u1175\u11c0"],["\ub52e","\u1103\u1175\u11c1"],["\ub52f","\u1103\u1175\u11c2"],["\ub530","\u1104\u1161"],["\ub531","\u1104\u1161\u11a8"],["\ub532","\u1104\u1161\u11a9"],["\ub533","\u1104\u1161\u11aa"],["\ub534","\u1104\u1161\u11ab"],["\ub535","\u1104\u1161\u11ac"],["\ub536","\u1104\u1161\u11ad"],["\ub537","\u1104\u1161\u11ae"],["\ub538","\u1104\u1161\u11af"],["\ub539","\u1104\u1161\u11b0"],["\ub53a","\u1104\u1161\u11b1"],["\ub53b","\u1104\u1161\u11b2"],["\ub53c","\u1104\u1161\u11b3"],["\ub53d","\u1104\u1161\u11b4"],["\ub53e","\u1104\u1161\u11b5"],["\ub53f","\u1104\u1161\u11b6"],["\ub540","\u1104\u1161\u11b7"],["\ub541","\u1104\u1161\u11b8"],["\ub542","\u1104\u1161\u11b9"],["\ub543","\u1104\u1161\u11ba"],["\ub544","\u1104\u1161\u11bb"],["\ub545","\u1104\u1161\u11bc"],["\ub546","\u1104\u1161\u11bd"],["\ub547","\u1104\u1161\u11be"],["\ub548","\u1104\u1161\u11bf"],["\ub549","\u1104\u1161\u11c0"],["\ub54a","\u1104\u1161\u11c1"],["\ub54b","\u1104\u1161\u11c2"],["\ub54c","\u1104\u1162"],["\ub54d","\u1104\u1162\u11a8"],["\ub54e","\u1104\u1162\u11a9"],["\ub54f","\u1104\u1162\u11aa"],["\ub550","\u1104\u1162\u11ab"],["\ub551","\u1104\u1162\u11ac"],["\ub552","\u1104\u1162\u11ad"],["\ub553","\u1104\u1162\u11ae"],["\ub554","\u1104\u1162\u11af"],["\ub555","\u1104\u1162\u11b0"],["\ub556","\u1104\u1162\u11b1"],["\ub557","\u1104\u1162\u11b2"],["\ub558","\u1104\u1162\u11b3"],["\ub559","\u1104\u1162\u11b4"],["\ub55a","\u1104\u1162\u11b5"],["\ub55b","\u1104\u1162\u11b6"],["\ub55c","\u1104\u1162\u11b7"],["\ub55d","\u1104\u1162\u11b8"],["\ub55e","\u1104\u1162\u11b9"],["\ub55f","\u1104\u1162\u11ba"],["\ub560","\u1104\u1162\u11bb"],["\ub561","\u1104\u1162\u11bc"],["\ub562","\u1104\u1162\u11bd"],["\ub563","\u1104\u1162\u11be"],["\ub564","\u1104\u1162\u11bf"],["\ub565","\u1104\u1162\u11c0"],["\ub566","\u1104\u1162\u11c1"],["\ub567","\u1104\u1162\u11c2"],["\ub568","\u1104\u1163"],["\ub569","\u1104\u1163\u11a8"],["\ub56a","\u1104\u1163\u11a9"],["\ub56b","\u1104\u1163\u11aa"],["\ub56c","\u1104\u1163\u11ab"],["\ub56d","\u1104\u1163\u11ac"],["\ub56e","\u1104\u1163\u11ad"],["\ub56f","\u1104\u1163\u11ae"],["\ub570","\u1104\u1163\u11af"],["\ub571","\u1104\u1163\u11b0"],["\ub572","\u1104\u1163\u11b1"],["\ub573","\u1104\u1163\u11b2"],["\ub574","\u1104\u1163\u11b3"],["\ub575","\u1104\u1163\u11b4"],["\ub576","\u1104\u1163\u11b5"],["\ub577","\u1104\u1163\u11b6"],["\ub578","\u1104\u1163\u11b7"],["\ub579","\u1104\u1163\u11b8"],["\ub57a","\u1104\u1163\u11b9"],["\ub57b","\u1104\u1163\u11ba"],["\ub57c","\u1104\u1163\u11bb"],["\ub57d","\u1104\u1163\u11bc"],["\ub57e","\u1104\u1163\u11bd"],["\ub57f","\u1104\u1163\u11be"],["\ub580","\u1104\u1163\u11bf"],["\ub581","\u1104\u1163\u11c0"],["\ub582","\u1104\u1163\u11c1"],["\ub583","\u1104\u1163\u11c2"],["\ub584","\u1104\u1164"],["\ub585","\u1104\u1164\u11a8"],["\ub586","\u1104\u1164\u11a9"],["\ub587","\u1104\u1164\u11aa"],["\ub588","\u1104\u1164\u11ab"],["\ub589","\u1104\u1164\u11ac"],["\ub58a","\u1104\u1164\u11ad"],["\ub58b","\u1104\u1164\u11ae"],["\ub58c","\u1104\u1164\u11af"],["\ub58d","\u1104\u1164\u11b0"],["\ub58e","\u1104\u1164\u11b1"],["\ub58f","\u1104\u1164\u11b2"],["\ub590","\u1104\u1164\u11b3"],["\ub591","\u1104\u1164\u11b4"],["\ub592","\u1104\u1164\u11b5"],["\ub593","\u1104\u1164\u11b6"],["\ub594","\u1104\u1164\u11b7"],["\ub595","\u1104\u1164\u11b8"],["\ub596","\u1104\u1164\u11b9"],["\ub597","\u1104\u1164\u11ba"],["\ub598","\u1104\u1164\u11bb"],["\ub599","\u1104\u1164\u11bc"],["\ub59a","\u1104\u1164\u11bd"],["\ub59b","\u1104\u1164\u11be"],["\ub59c","\u1104\u1164\u11bf"],["\ub59d","\u1104\u1164\u11c0"],["\ub59e","\u1104\u1164\u11c1"],["\ub59f","\u1104\u1164\u11c2"],["\ub5a0","\u1104\u1165"],["\ub5a1","\u1104\u1165\u11a8"],["\ub5a2","\u1104\u1165\u11a9"],["\ub5a3","\u1104\u1165\u11aa"],["\ub5a4","\u1104\u1165\u11ab"],["\ub5a5","\u1104\u1165\u11ac"],["\ub5a6","\u1104\u1165\u11ad"],["\ub5a7","\u1104\u1165\u11ae"],["\ub5a8","\u1104\u1165\u11af"],["\ub5a9","\u1104\u1165\u11b0"],["\ub5aa","\u1104\u1165\u11b1"],["\ub5ab","\u1104\u1165\u11b2"],["\ub5ac","\u1104\u1165\u11b3"],["\ub5ad","\u1104\u1165\u11b4"],["\ub5ae","\u1104\u1165\u11b5"],["\ub5af","\u1104\u1165\u11b6"],["\ub5b0","\u1104\u1165\u11b7"],["\ub5b1","\u1104\u1165\u11b8"],["\ub5b2","\u1104\u1165\u11b9"],["\ub5b3","\u1104\u1165\u11ba"],["\ub5b4","\u1104\u1165\u11bb"],["\ub5b5","\u1104\u1165\u11bc"],["\ub5b6","\u1104\u1165\u11bd"],["\ub5b7","\u1104\u1165\u11be"],["\ub5b8","\u1104\u1165\u11bf"],["\ub5b9","\u1104\u1165\u11c0"],["\ub5ba","\u1104\u1165\u11c1"],["\ub5bb","\u1104\u1165\u11c2"],["\ub5bc","\u1104\u1166"],["\ub5bd","\u1104\u1166\u11a8"],["\ub5be","\u1104\u1166\u11a9"],["\ub5bf","\u1104\u1166\u11aa"],["\ub5c0","\u1104\u1166\u11ab"],["\ub5c1","\u1104\u1166\u11ac"],["\ub5c2","\u1104\u1166\u11ad"],["\ub5c3","\u1104\u1166\u11ae"],["\ub5c4","\u1104\u1166\u11af"],["\ub5c5","\u1104\u1166\u11b0"],["\ub5c6","\u1104\u1166\u11b1"],["\ub5c7","\u1104\u1166\u11b2"],["\ub5c8","\u1104\u1166\u11b3"],["\ub5c9","\u1104\u1166\u11b4"],["\ub5ca","\u1104\u1166\u11b5"],["\ub5cb","\u1104\u1166\u11b6"],["\ub5cc","\u1104\u1166\u11b7"],["\ub5cd","\u1104\u1166\u11b8"],["\ub5ce","\u1104\u1166\u11b9"],["\ub5cf","\u1104\u1166\u11ba"],["\ub5d0","\u1104\u1166\u11bb"],["\ub5d1","\u1104\u1166\u11bc"],["\ub5d2","\u1104\u1166\u11bd"],["\ub5d3","\u1104\u1166\u11be"],["\ub5d4","\u1104\u1166\u11bf"],["\ub5d5","\u1104\u1166\u11c0"],["\ub5d6","\u1104\u1166\u11c1"],["\ub5d7","\u1104\u1166\u11c2"],["\ub5d8","\u1104\u1167"],["\ub5d9","\u1104\u1167\u11a8"],["\ub5da","\u1104\u1167\u11a9"],["\ub5db","\u1104\u1167\u11aa"],["\ub5dc","\u1104\u1167\u11ab"],["\ub5dd","\u1104\u1167\u11ac"],["\ub5de","\u1104\u1167\u11ad"],["\ub5df","\u1104\u1167\u11ae"],["\ub5e0","\u1104\u1167\u11af"],["\ub5e1","\u1104\u1167\u11b0"],["\ub5e2","\u1104\u1167\u11b1"],["\ub5e3","\u1104\u1167\u11b2"],["\ub5e4","\u1104\u1167\u11b3"],["\ub5e5","\u1104\u1167\u11b4"],["\ub5e6","\u1104\u1167\u11b5"],["\ub5e7","\u1104\u1167\u11b6"],["\ub5e8","\u1104\u1167\u11b7"],["\ub5e9","\u1104\u1167\u11b8"],["\ub5ea","\u1104\u1167\u11b9"],["\ub5eb","\u1104\u1167\u11ba"],["\ub5ec","\u1104\u1167\u11bb"],["\ub5ed","\u1104\u1167\u11bc"],["\ub5ee","\u1104\u1167\u11bd"],["\ub5ef","\u1104\u1167\u11be"],["\ub5f0","\u1104\u1167\u11bf"],["\ub5f1","\u1104\u1167\u11c0"],["\ub5f2","\u1104\u1167\u11c1"],["\ub5f3","\u1104\u1167\u11c2"],["\ub5f4","\u1104\u1168"],["\ub5f5","\u1104\u1168\u11a8"],["\ub5f6","\u1104\u1168\u11a9"],["\ub5f7","\u1104\u1168\u11aa"],["\ub5f8","\u1104\u1168\u11ab"],["\ub5f9","\u1104\u1168\u11ac"],["\ub5fa","\u1104\u1168\u11ad"],["\ub5fb","\u1104\u1168\u11ae"],["\ub5fc","\u1104\u1168\u11af"],["\ub5fd","\u1104\u1168\u11b0"],["\ub5fe","\u1104\u1168\u11b1"],["\ub5ff","\u1104\u1168\u11b2"],["\ub600","\u1104\u1168\u11b3"],["\ub601","\u1104\u1168\u11b4"],["\ub602","\u1104\u1168\u11b5"],["\ub603","\u1104\u1168\u11b6"],["\ub604","\u1104\u1168\u11b7"],["\ub605","\u1104\u1168\u11b8"],["\ub606","\u1104\u1168\u11b9"],["\ub607","\u1104\u1168\u11ba"],["\ub608","\u1104\u1168\u11bb"],["\ub609","\u1104\u1168\u11bc"],["\ub60a","\u1104\u1168\u11bd"],["\ub60b","\u1104\u1168\u11be"],["\ub60c","\u1104\u1168\u11bf"],["\ub60d","\u1104\u1168\u11c0"],["\ub60e","\u1104\u1168\u11c1"],["\ub60f","\u1104\u1168\u11c2"],["\ub610","\u1104\u1169"],["\ub611","\u1104\u1169\u11a8"],["\ub612","\u1104\u1169\u11a9"],["\ub613","\u1104\u1169\u11aa"],["\ub614","\u1104\u1169\u11ab"],["\ub615","\u1104\u1169\u11ac"],["\ub616","\u1104\u1169\u11ad"],["\ub617","\u1104\u1169\u11ae"],["\ub618","\u1104\u1169\u11af"],["\ub619","\u1104\u1169\u11b0"],["\ub61a","\u1104\u1169\u11b1"],["\ub61b","\u1104\u1169\u11b2"],["\ub61c","\u1104\u1169\u11b3"],["\ub61d","\u1104\u1169\u11b4"],["\ub61e","\u1104\u1169\u11b5"],["\ub61f","\u1104\u1169\u11b6"],["\ub620","\u1104\u1169\u11b7"],["\ub621","\u1104\u1169\u11b8"],["\ub622","\u1104\u1169\u11b9"],["\ub623","\u1104\u1169\u11ba"],["\ub624","\u1104\u1169\u11bb"],["\ub625","\u1104\u1169\u11bc"],["\ub626","\u1104\u1169\u11bd"],["\ub627","\u1104\u1169\u11be"],["\ub628","\u1104\u1169\u11bf"],["\ub629","\u1104\u1169\u11c0"],["\ub62a","\u1104\u1169\u11c1"],["\ub62b","\u1104\u1169\u11c2"],["\ub62c","\u1104\u116a"],["\ub62d","\u1104\u116a\u11a8"],["\ub62e","\u1104\u116a\u11a9"],["\ub62f","\u1104\u116a\u11aa"],["\ub630","\u1104\u116a\u11ab"],["\ub631","\u1104\u116a\u11ac"],["\ub632","\u1104\u116a\u11ad"],["\ub633","\u1104\u116a\u11ae"],["\ub634","\u1104\u116a\u11af"],["\ub635","\u1104\u116a\u11b0"],["\ub636","\u1104\u116a\u11b1"],["\ub637","\u1104\u116a\u11b2"],["\ub638","\u1104\u116a\u11b3"],["\ub639","\u1104\u116a\u11b4"],["\ub63a","\u1104\u116a\u11b5"],["\ub63b","\u1104\u116a\u11b6"],["\ub63c","\u1104\u116a\u11b7"],["\ub63d","\u1104\u116a\u11b8"],["\ub63e","\u1104\u116a\u11b9"],["\ub63f","\u1104\u116a\u11ba"],["\ub640","\u1104\u116a\u11bb"],["\ub641","\u1104\u116a\u11bc"],["\ub642","\u1104\u116a\u11bd"],["\ub643","\u1104\u116a\u11be"],["\ub644","\u1104\u116a\u11bf"],["\ub645","\u1104\u116a\u11c0"],["\ub646","\u1104\u116a\u11c1"],["\ub647","\u1104\u116a\u11c2"],["\ub648","\u1104\u116b"],["\ub649","\u1104\u116b\u11a8"],["\ub64a","\u1104\u116b\u11a9"],["\ub64b","\u1104\u116b\u11aa"],["\ub64c","\u1104\u116b\u11ab"],["\ub64d","\u1104\u116b\u11ac"],["\ub64e","\u1104\u116b\u11ad"],["\ub64f","\u1104\u116b\u11ae"],["\ub650","\u1104\u116b\u11af"],["\ub651","\u1104\u116b\u11b0"],["\ub652","\u1104\u116b\u11b1"],["\ub653","\u1104\u116b\u11b2"],["\ub654","\u1104\u116b\u11b3"],["\ub655","\u1104\u116b\u11b4"],["\ub656","\u1104\u116b\u11b5"],["\ub657","\u1104\u116b\u11b6"],["\ub658","\u1104\u116b\u11b7"],["\ub659","\u1104\u116b\u11b8"],["\ub65a","\u1104\u116b\u11b9"],["\ub65b","\u1104\u116b\u11ba"],["\ub65c","\u1104\u116b\u11bb"],["\ub65d","\u1104\u116b\u11bc"],["\ub65e","\u1104\u116b\u11bd"],["\ub65f","\u1104\u116b\u11be"],["\ub660","\u1104\u116b\u11bf"],["\ub661","\u1104\u116b\u11c0"],["\ub662","\u1104\u116b\u11c1"],["\ub663","\u1104\u116b\u11c2"],["\ub664","\u1104\u116c"],["\ub665","\u1104\u116c\u11a8"],["\ub666","\u1104\u116c\u11a9"],["\ub667","\u1104\u116c\u11aa"],["\ub668","\u1104\u116c\u11ab"],["\ub669","\u1104\u116c\u11ac"],["\ub66a","\u1104\u116c\u11ad"],["\ub66b","\u1104\u116c\u11ae"],["\ub66c","\u1104\u116c\u11af"],["\ub66d","\u1104\u116c\u11b0"],["\ub66e","\u1104\u116c\u11b1"],["\ub66f","\u1104\u116c\u11b2"],["\ub670","\u1104\u116c\u11b3"],["\ub671","\u1104\u116c\u11b4"],["\ub672","\u1104\u116c\u11b5"],["\ub673","\u1104\u116c\u11b6"],["\ub674","\u1104\u116c\u11b7"],["\ub675","\u1104\u116c\u11b8"],["\ub676","\u1104\u116c\u11b9"],["\ub677","\u1104\u116c\u11ba"],["\ub678","\u1104\u116c\u11bb"],["\ub679","\u1104\u116c\u11bc"],["\ub67a","\u1104\u116c\u11bd"],["\ub67b","\u1104\u116c\u11be"],["\ub67c","\u1104\u116c\u11bf"],["\ub67d","\u1104\u116c\u11c0"],["\ub67e","\u1104\u116c\u11c1"],["\ub67f","\u1104\u116c\u11c2"],["\ub680","\u1104\u116d"],["\ub681","\u1104\u116d\u11a8"],["\ub682","\u1104\u116d\u11a9"],["\ub683","\u1104\u116d\u11aa"],["\ub684","\u1104\u116d\u11ab"],["\ub685","\u1104\u116d\u11ac"],["\ub686","\u1104\u116d\u11ad"],["\ub687","\u1104\u116d\u11ae"],["\ub688","\u1104\u116d\u11af"],["\ub689","\u1104\u116d\u11b0"],["\ub68a","\u1104\u116d\u11b1"],["\ub68b","\u1104\u116d\u11b2"],["\ub68c","\u1104\u116d\u11b3"],["\ub68d","\u1104\u116d\u11b4"],["\ub68e","\u1104\u116d\u11b5"],["\ub68f","\u1104\u116d\u11b6"],["\ub690","\u1104\u116d\u11b7"],["\ub691","\u1104\u116d\u11b8"],["\ub692","\u1104\u116d\u11b9"],["\ub693","\u1104\u116d\u11ba"],["\ub694","\u1104\u116d\u11bb"],["\ub695","\u1104\u116d\u11bc"],["\ub696","\u1104\u116d\u11bd"],["\ub697","\u1104\u116d\u11be"],["\ub698","\u1104\u116d\u11bf"],["\ub699","\u1104\u116d\u11c0"],["\ub69a","\u1104\u116d\u11c1"],["\ub69b","\u1104\u116d\u11c2"],["\ub69c","\u1104\u116e"],["\ub69d","\u1104\u116e\u11a8"],["\ub69e","\u1104\u116e\u11a9"],["\ub69f","\u1104\u116e\u11aa"],["\ub6a0","\u1104\u116e\u11ab"],["\ub6a1","\u1104\u116e\u11ac"],["\ub6a2","\u1104\u116e\u11ad"],["\ub6a3","\u1104\u116e\u11ae"],["\ub6a4","\u1104\u116e\u11af"],["\ub6a5","\u1104\u116e\u11b0"],["\ub6a6","\u1104\u116e\u11b1"],["\ub6a7","\u1104\u116e\u11b2"],["\ub6a8","\u1104\u116e\u11b3"],["\ub6a9","\u1104\u116e\u11b4"],["\ub6aa","\u1104\u116e\u11b5"],["\ub6ab","\u1104\u116e\u11b6"],["\ub6ac","\u1104\u116e\u11b7"],["\ub6ad","\u1104\u116e\u11b8"],["\ub6ae","\u1104\u116e\u11b9"],["\ub6af","\u1104\u116e\u11ba"],["\ub6b0","\u1104\u116e\u11bb"],["\ub6b1","\u1104\u116e\u11bc"],["\ub6b2","\u1104\u116e\u11bd"],["\ub6b3","\u1104\u116e\u11be"],["\ub6b4","\u1104\u116e\u11bf"],["\ub6b5","\u1104\u116e\u11c0"],["\ub6b6","\u1104\u116e\u11c1"],["\ub6b7","\u1104\u116e\u11c2"],["\ub6b8","\u1104\u116f"],["\ub6b9","\u1104\u116f\u11a8"],["\ub6ba","\u1104\u116f\u11a9"],["\ub6bb","\u1104\u116f\u11aa"],["\ub6bc","\u1104\u116f\u11ab"],["\ub6bd","\u1104\u116f\u11ac"],["\ub6be","\u1104\u116f\u11ad"],["\ub6bf","\u1104\u116f\u11ae"],["\ub6c0","\u1104\u116f\u11af"],["\ub6c1","\u1104\u116f\u11b0"],["\ub6c2","\u1104\u116f\u11b1"],["\ub6c3","\u1104\u116f\u11b2"],["\ub6c4","\u1104\u116f\u11b3"],["\ub6c5","\u1104\u116f\u11b4"],["\ub6c6","\u1104\u116f\u11b5"],["\ub6c7","\u1104\u116f\u11b6"],["\ub6c8","\u1104\u116f\u11b7"],["\ub6c9","\u1104\u116f\u11b8"],["\ub6ca","\u1104\u116f\u11b9"],["\ub6cb","\u1104\u116f\u11ba"],["\ub6cc","\u1104\u116f\u11bb"],["\ub6cd","\u1104\u116f\u11bc"],["\ub6ce","\u1104\u116f\u11bd"],["\ub6cf","\u1104\u116f\u11be"],["\ub6d0","\u1104\u116f\u11bf"],["\ub6d1","\u1104\u116f\u11c0"],["\ub6d2","\u1104\u116f\u11c1"],["\ub6d3","\u1104\u116f\u11c2"],["\ub6d4","\u1104\u1170"],["\ub6d5","\u1104\u1170\u11a8"],["\ub6d6","\u1104\u1170\u11a9"],["\ub6d7","\u1104\u1170\u11aa"],["\ub6d8","\u1104\u1170\u11ab"],["\ub6d9","\u1104\u1170\u11ac"],["\ub6da","\u1104\u1170\u11ad"],["\ub6db","\u1104\u1170\u11ae"],["\ub6dc","\u1104\u1170\u11af"],["\ub6dd","\u1104\u1170\u11b0"],["\ub6de","\u1104\u1170\u11b1"],["\ub6df","\u1104\u1170\u11b2"],["\ub6e0","\u1104\u1170\u11b3"],["\ub6e1","\u1104\u1170\u11b4"],["\ub6e2","\u1104\u1170\u11b5"],["\ub6e3","\u1104\u1170\u11b6"],["\ub6e4","\u1104\u1170\u11b7"],["\ub6e5","\u1104\u1170\u11b8"],["\ub6e6","\u1104\u1170\u11b9"],["\ub6e7","\u1104\u1170\u11ba"],["\ub6e8","\u1104\u1170\u11bb"],["\ub6e9","\u1104\u1170\u11bc"],["\ub6ea","\u1104\u1170\u11bd"],["\ub6eb","\u1104\u1170\u11be"],["\ub6ec","\u1104\u1170\u11bf"],["\ub6ed","\u1104\u1170\u11c0"],["\ub6ee","\u1104\u1170\u11c1"],["\ub6ef","\u1104\u1170\u11c2"],["\ub6f0","\u1104\u1171"],["\ub6f1","\u1104\u1171\u11a8"],["\ub6f2","\u1104\u1171\u11a9"],["\ub6f3","\u1104\u1171\u11aa"],["\ub6f4","\u1104\u1171\u11ab"],["\ub6f5","\u1104\u1171\u11ac"],["\ub6f6","\u1104\u1171\u11ad"],["\ub6f7","\u1104\u1171\u11ae"],["\ub6f8","\u1104\u1171\u11af"],["\ub6f9","\u1104\u1171\u11b0"],["\ub6fa","\u1104\u1171\u11b1"],["\ub6fb","\u1104\u1171\u11b2"],["\ub6fc","\u1104\u1171\u11b3"],["\ub6fd","\u1104\u1171\u11b4"],["\ub6fe","\u1104\u1171\u11b5"],["\ub6ff","\u1104\u1171\u11b6"],["\ub700","\u1104\u1171\u11b7"],["\ub701","\u1104\u1171\u11b8"],["\ub702","\u1104\u1171\u11b9"],["\ub703","\u1104\u1171\u11ba"],["\ub704","\u1104\u1171\u11bb"],["\ub705","\u1104\u1171\u11bc"],["\ub706","\u1104\u1171\u11bd"],["\ub707","\u1104\u1171\u11be"],["\ub708","\u1104\u1171\u11bf"],["\ub709","\u1104\u1171\u11c0"],["\ub70a","\u1104\u1171\u11c1"],["\ub70b","\u1104\u1171\u11c2"],["\ub70c","\u1104\u1172"],["\ub70d","\u1104\u1172\u11a8"],["\ub70e","\u1104\u1172\u11a9"],["\ub70f","\u1104\u1172\u11aa"],["\ub710","\u1104\u1172\u11ab"],["\ub711","\u1104\u1172\u11ac"],["\ub712","\u1104\u1172\u11ad"],["\ub713","\u1104\u1172\u11ae"],["\ub714","\u1104\u1172\u11af"],["\ub715","\u1104\u1172\u11b0"],["\ub716","\u1104\u1172\u11b1"],["\ub717","\u1104\u1172\u11b2"],["\ub718","\u1104\u1172\u11b3"],["\ub719","\u1104\u1172\u11b4"],["\ub71a","\u1104\u1172\u11b5"],["\ub71b","\u1104\u1172\u11b6"],["\ub71c","\u1104\u1172\u11b7"],["\ub71d","\u1104\u1172\u11b8"],["\ub71e","\u1104\u1172\u11b9"],["\ub71f","\u1104\u1172\u11ba"],["\ub720","\u1104\u1172\u11bb"],["\ub721","\u1104\u1172\u11bc"],["\ub722","\u1104\u1172\u11bd"],["\ub723","\u1104\u1172\u11be"],["\ub724","\u1104\u1172\u11bf"],["\ub725","\u1104\u1172\u11c0"],["\ub726","\u1104\u1172\u11c1"],["\ub727","\u1104\u1172\u11c2"],["\ub728","\u1104\u1173"],["\ub729","\u1104\u1173\u11a8"],["\ub72a","\u1104\u1173\u11a9"],["\ub72b","\u1104\u1173\u11aa"],["\ub72c","\u1104\u1173\u11ab"],["\ub72d","\u1104\u1173\u11ac"],["\ub72e","\u1104\u1173\u11ad"],["\ub72f","\u1104\u1173\u11ae"],["\ub730","\u1104\u1173\u11af"],["\ub731","\u1104\u1173\u11b0"],["\ub732","\u1104\u1173\u11b1"],["\ub733","\u1104\u1173\u11b2"],["\ub734","\u1104\u1173\u11b3"],["\ub735","\u1104\u1173\u11b4"],["\ub736","\u1104\u1173\u11b5"],["\ub737","\u1104\u1173\u11b6"],["\ub738","\u1104\u1173\u11b7"],["\ub739","\u1104\u1173\u11b8"],["\ub73a","\u1104\u1173\u11b9"],["\ub73b","\u1104\u1173\u11ba"],["\ub73c","\u1104\u1173\u11bb"],["\ub73d","\u1104\u1173\u11bc"],["\ub73e","\u1104\u1173\u11bd"],["\ub73f","\u1104\u1173\u11be"],["\ub740","\u1104\u1173\u11bf"],["\ub741","\u1104\u1173\u11c0"],["\ub742","\u1104\u1173\u11c1"],["\ub743","\u1104\u1173\u11c2"],["\ub744","\u1104\u1174"],["\ub745","\u1104\u1174\u11a8"],["\ub746","\u1104\u1174\u11a9"],["\ub747","\u1104\u1174\u11aa"],["\ub748","\u1104\u1174\u11ab"],["\ub749","\u1104\u1174\u11ac"],["\ub74a","\u1104\u1174\u11ad"],["\ub74b","\u1104\u1174\u11ae"],["\ub74c","\u1104\u1174\u11af"],["\ub74d","\u1104\u1174\u11b0"],["\ub74e","\u1104\u1174\u11b1"],["\ub74f","\u1104\u1174\u11b2"],["\ub750","\u1104\u1174\u11b3"],["\ub751","\u1104\u1174\u11b4"],["\ub752","\u1104\u1174\u11b5"],["\ub753","\u1104\u1174\u11b6"],["\ub754","\u1104\u1174\u11b7"],["\ub755","\u1104\u1174\u11b8"],["\ub756","\u1104\u1174\u11b9"],["\ub757","\u1104\u1174\u11ba"],["\ub758","\u1104\u1174\u11bb"],["\ub759","\u1104\u1174\u11bc"],["\ub75a","\u1104\u1174\u11bd"],["\ub75b","\u1104\u1174\u11be"],["\ub75c","\u1104\u1174\u11bf"],["\ub75d","\u1104\u1174\u11c0"],["\ub75e","\u1104\u1174\u11c1"],["\ub75f","\u1104\u1174\u11c2"],["\ub760","\u1104\u1175"],["\ub761","\u1104\u1175\u11a8"],["\ub762","\u1104\u1175\u11a9"],["\ub763","\u1104\u1175\u11aa"],["\ub764","\u1104\u1175\u11ab"],["\ub765","\u1104\u1175\u11ac"],["\ub766","\u1104\u1175\u11ad"],["\ub767","\u1104\u1175\u11ae"],["\ub768","\u1104\u1175\u11af"],["\ub769","\u1104\u1175\u11b0"],["\ub76a","\u1104\u1175\u11b1"],["\ub76b","\u1104\u1175\u11b2"],["\ub76c","\u1104\u1175\u11b3"],["\ub76d","\u1104\u1175\u11b4"],["\ub76e","\u1104\u1175\u11b5"],["\ub76f","\u1104\u1175\u11b6"],["\ub770","\u1104\u1175\u11b7"],["\ub771","\u1104\u1175\u11b8"],["\ub772","\u1104\u1175\u11b9"],["\ub773","\u1104\u1175\u11ba"],["\ub774","\u1104\u1175\u11bb"],["\ub775","\u1104\u1175\u11bc"],["\ub776","\u1104\u1175\u11bd"],["\ub777","\u1104\u1175\u11be"],["\ub778","\u1104\u1175\u11bf"],["\ub779","\u1104\u1175\u11c0"],["\ub77a","\u1104\u1175\u11c1"],["\ub77b","\u1104\u1175\u11c2"],["\ub77c","\u1105\u1161"],["\ub77d","\u1105\u1161\u11a8"],["\ub77e","\u1105\u1161\u11a9"],["\ub77f","\u1105\u1161\u11aa"],["\ub780","\u1105\u1161\u11ab"],["\ub781","\u1105\u1161\u11ac"],["\ub782","\u1105\u1161\u11ad"],["\ub783","\u1105\u1161\u11ae"],["\ub784","\u1105\u1161\u11af"],["\ub785","\u1105\u1161\u11b0"],["\ub786","\u1105\u1161\u11b1"],["\ub787","\u1105\u1161\u11b2"],["\ub788","\u1105\u1161\u11b3"],["\ub789","\u1105\u1161\u11b4"],["\ub78a","\u1105\u1161\u11b5"],["\ub78b","\u1105\u1161\u11b6"],["\ub78c","\u1105\u1161\u11b7"],["\ub78d","\u1105\u1161\u11b8"],["\ub78e","\u1105\u1161\u11b9"],["\ub78f","\u1105\u1161\u11ba"],["\ub790","\u1105\u1161\u11bb"],["\ub791","\u1105\u1161\u11bc"],["\ub792","\u1105\u1161\u11bd"],["\ub793","\u1105\u1161\u11be"],["\ub794","\u1105\u1161\u11bf"],["\ub795","\u1105\u1161\u11c0"],["\ub796","\u1105\u1161\u11c1"],["\ub797","\u1105\u1161\u11c2"],["\ub798","\u1105\u1162"],["\ub799","\u1105\u1162\u11a8"],["\ub79a","\u1105\u1162\u11a9"],["\ub79b","\u1105\u1162\u11aa"],["\ub79c","\u1105\u1162\u11ab"],["\ub79d","\u1105\u1162\u11ac"],["\ub79e","\u1105\u1162\u11ad"],["\ub79f","\u1105\u1162\u11ae"],["\ub7a0","\u1105\u1162\u11af"],["\ub7a1","\u1105\u1162\u11b0"],["\ub7a2","\u1105\u1162\u11b1"],["\ub7a3","\u1105\u1162\u11b2"],["\ub7a4","\u1105\u1162\u11b3"],["\ub7a5","\u1105\u1162\u11b4"],["\ub7a6","\u1105\u1162\u11b5"],["\ub7a7","\u1105\u1162\u11b6"],["\ub7a8","\u1105\u1162\u11b7"],["\ub7a9","\u1105\u1162\u11b8"],["\ub7aa","\u1105\u1162\u11b9"],["\ub7ab","\u1105\u1162\u11ba"],["\ub7ac","\u1105\u1162\u11bb"],["\ub7ad","\u1105\u1162\u11bc"],["\ub7ae","\u1105\u1162\u11bd"],["\ub7af","\u1105\u1162\u11be"],["\ub7b0","\u1105\u1162\u11bf"],["\ub7b1","\u1105\u1162\u11c0"],["\ub7b2","\u1105\u1162\u11c1"],["\ub7b3","\u1105\u1162\u11c2"],["\ub7b4","\u1105\u1163"],["\ub7b5","\u1105\u1163\u11a8"],["\ub7b6","\u1105\u1163\u11a9"],["\ub7b7","\u1105\u1163\u11aa"],["\ub7b8","\u1105\u1163\u11ab"],["\ub7b9","\u1105\u1163\u11ac"],["\ub7ba","\u1105\u1163\u11ad"],["\ub7bb","\u1105\u1163\u11ae"],["\ub7bc","\u1105\u1163\u11af"],["\ub7bd","\u1105\u1163\u11b0"],["\ub7be","\u1105\u1163\u11b1"],["\ub7bf","\u1105\u1163\u11b2"],["\ub7c0","\u1105\u1163\u11b3"],["\ub7c1","\u1105\u1163\u11b4"],["\ub7c2","\u1105\u1163\u11b5"],["\ub7c3","\u1105\u1163\u11b6"],["\ub7c4","\u1105\u1163\u11b7"],["\ub7c5","\u1105\u1163\u11b8"],["\ub7c6","\u1105\u1163\u11b9"],["\ub7c7","\u1105\u1163\u11ba"],["\ub7c8","\u1105\u1163\u11bb"],["\ub7c9","\u1105\u1163\u11bc"],["\ub7ca","\u1105\u1163\u11bd"],["\ub7cb","\u1105\u1163\u11be"],["\ub7cc","\u1105\u1163\u11bf"],["\ub7cd","\u1105\u1163\u11c0"],["\ub7ce","\u1105\u1163\u11c1"],["\ub7cf","\u1105\u1163\u11c2"],["\ub7d0","\u1105\u1164"],["\ub7d1","\u1105\u1164\u11a8"],["\ub7d2","\u1105\u1164\u11a9"],["\ub7d3","\u1105\u1164\u11aa"],["\ub7d4","\u1105\u1164\u11ab"],["\ub7d5","\u1105\u1164\u11ac"],["\ub7d6","\u1105\u1164\u11ad"],["\ub7d7","\u1105\u1164\u11ae"],["\ub7d8","\u1105\u1164\u11af"],["\ub7d9","\u1105\u1164\u11b0"],["\ub7da","\u1105\u1164\u11b1"],["\ub7db","\u1105\u1164\u11b2"],["\ub7dc","\u1105\u1164\u11b3"],["\ub7dd","\u1105\u1164\u11b4"],["\ub7de","\u1105\u1164\u11b5"],["\ub7df","\u1105\u1164\u11b6"],["\ub7e0","\u1105\u1164\u11b7"],["\ub7e1","\u1105\u1164\u11b8"],["\ub7e2","\u1105\u1164\u11b9"],["\ub7e3","\u1105\u1164\u11ba"],["\ub7e4","\u1105\u1164\u11bb"],["\ub7e5","\u1105\u1164\u11bc"],["\ub7e6","\u1105\u1164\u11bd"],["\ub7e7","\u1105\u1164\u11be"],["\ub7e8","\u1105\u1164\u11bf"],["\ub7e9","\u1105\u1164\u11c0"],["\ub7ea","\u1105\u1164\u11c1"],["\ub7eb","\u1105\u1164\u11c2"],["\ub7ec","\u1105\u1165"],["\ub7ed","\u1105\u1165\u11a8"],["\ub7ee","\u1105\u1165\u11a9"],["\ub7ef","\u1105\u1165\u11aa"],["\ub7f0","\u1105\u1165\u11ab"],["\ub7f1","\u1105\u1165\u11ac"],["\ub7f2","\u1105\u1165\u11ad"],["\ub7f3","\u1105\u1165\u11ae"],["\ub7f4","\u1105\u1165\u11af"],["\ub7f5","\u1105\u1165\u11b0"],["\ub7f6","\u1105\u1165\u11b1"],["\ub7f7","\u1105\u1165\u11b2"],["\ub7f8","\u1105\u1165\u11b3"],["\ub7f9","\u1105\u1165\u11b4"],["\ub7fa","\u1105\u1165\u11b5"],["\ub7fb","\u1105\u1165\u11b6"],["\ub7fc","\u1105\u1165\u11b7"],["\ub7fd","\u1105\u1165\u11b8"],["\ub7fe","\u1105\u1165\u11b9"],["\ub7ff","\u1105\u1165\u11ba"],["\ub800","\u1105\u1165\u11bb"],["\ub801","\u1105\u1165\u11bc"],["\ub802","\u1105\u1165\u11bd"],["\ub803","\u1105\u1165\u11be"],["\ub804","\u1105\u1165\u11bf"],["\ub805","\u1105\u1165\u11c0"],["\ub806","\u1105\u1165\u11c1"],["\ub807","\u1105\u1165\u11c2"],["\ub808","\u1105\u1166"],["\ub809","\u1105\u1166\u11a8"],["\ub80a","\u1105\u1166\u11a9"],["\ub80b","\u1105\u1166\u11aa"],["\ub80c","\u1105\u1166\u11ab"],["\ub80d","\u1105\u1166\u11ac"],["\ub80e","\u1105\u1166\u11ad"],["\ub80f","\u1105\u1166\u11ae"],["\ub810","\u1105\u1166\u11af"],["\ub811","\u1105\u1166\u11b0"],["\ub812","\u1105\u1166\u11b1"],["\ub813","\u1105\u1166\u11b2"],["\ub814","\u1105\u1166\u11b3"],["\ub815","\u1105\u1166\u11b4"],["\ub816","\u1105\u1166\u11b5"],["\ub817","\u1105\u1166\u11b6"],["\ub818","\u1105\u1166\u11b7"],["\ub819","\u1105\u1166\u11b8"],["\ub81a","\u1105\u1166\u11b9"],["\ub81b","\u1105\u1166\u11ba"],["\ub81c","\u1105\u1166\u11bb"],["\ub81d","\u1105\u1166\u11bc"],["\ub81e","\u1105\u1166\u11bd"],["\ub81f","\u1105\u1166\u11be"],["\ub820","\u1105\u1166\u11bf"],["\ub821","\u1105\u1166\u11c0"],["\ub822","\u1105\u1166\u11c1"],["\ub823","\u1105\u1166\u11c2"],["\ub824","\u1105\u1167"],["\ub825","\u1105\u1167\u11a8"],["\ub826","\u1105\u1167\u11a9"],["\ub827","\u1105\u1167\u11aa"],["\ub828","\u1105\u1167\u11ab"],["\ub829","\u1105\u1167\u11ac"],["\ub82a","\u1105\u1167\u11ad"],["\ub82b","\u1105\u1167\u11ae"],["\ub82c","\u1105\u1167\u11af"],["\ub82d","\u1105\u1167\u11b0"],["\ub82e","\u1105\u1167\u11b1"],["\ub82f","\u1105\u1167\u11b2"],["\ub830","\u1105\u1167\u11b3"],["\ub831","\u1105\u1167\u11b4"],["\ub832","\u1105\u1167\u11b5"],["\ub833","\u1105\u1167\u11b6"],["\ub834","\u1105\u1167\u11b7"],["\ub835","\u1105\u1167\u11b8"],["\ub836","\u1105\u1167\u11b9"],["\ub837","\u1105\u1167\u11ba"],["\ub838","\u1105\u1167\u11bb"],["\ub839","\u1105\u1167\u11bc"],["\ub83a","\u1105\u1167\u11bd"],["\ub83b","\u1105\u1167\u11be"],["\ub83c","\u1105\u1167\u11bf"],["\ub83d","\u1105\u1167\u11c0"],["\ub83e","\u1105\u1167\u11c1"],["\ub83f","\u1105\u1167\u11c2"],["\ub840","\u1105\u1168"],["\ub841","\u1105\u1168\u11a8"],["\ub842","\u1105\u1168\u11a9"],["\ub843","\u1105\u1168\u11aa"],["\ub844","\u1105\u1168\u11ab"],["\ub845","\u1105\u1168\u11ac"],["\ub846","\u1105\u1168\u11ad"],["\ub847","\u1105\u1168\u11ae"],["\ub848","\u1105\u1168\u11af"],["\ub849","\u1105\u1168\u11b0"],["\ub84a","\u1105\u1168\u11b1"],["\ub84b","\u1105\u1168\u11b2"],["\ub84c","\u1105\u1168\u11b3"],["\ub84d","\u1105\u1168\u11b4"],["\ub84e","\u1105\u1168\u11b5"],["\ub84f","\u1105\u1168\u11b6"],["\ub850","\u1105\u1168\u11b7"],["\ub851","\u1105\u1168\u11b8"],["\ub852","\u1105\u1168\u11b9"],["\ub853","\u1105\u1168\u11ba"],["\ub854","\u1105\u1168\u11bb"],["\ub855","\u1105\u1168\u11bc"],["\ub856","\u1105\u1168\u11bd"],["\ub857","\u1105\u1168\u11be"],["\ub858","\u1105\u1168\u11bf"],["\ub859","\u1105\u1168\u11c0"],["\ub85a","\u1105\u1168\u11c1"],["\ub85b","\u1105\u1168\u11c2"],["\ub85c","\u1105\u1169"],["\ub85d","\u1105\u1169\u11a8"],["\ub85e","\u1105\u1169\u11a9"],["\ub85f","\u1105\u1169\u11aa"],["\ub860","\u1105\u1169\u11ab"],["\ub861","\u1105\u1169\u11ac"],["\ub862","\u1105\u1169\u11ad"],["\ub863","\u1105\u1169\u11ae"],["\ub864","\u1105\u1169\u11af"],["\ub865","\u1105\u1169\u11b0"],["\ub866","\u1105\u1169\u11b1"],["\ub867","\u1105\u1169\u11b2"],["\ub868","\u1105\u1169\u11b3"],["\ub869","\u1105\u1169\u11b4"],["\ub86a","\u1105\u1169\u11b5"],["\ub86b","\u1105\u1169\u11b6"],["\ub86c","\u1105\u1169\u11b7"],["\ub86d","\u1105\u1169\u11b8"],["\ub86e","\u1105\u1169\u11b9"],["\ub86f","\u1105\u1169\u11ba"],["\ub870","\u1105\u1169\u11bb"],["\ub871","\u1105\u1169\u11bc"],["\ub872","\u1105\u1169\u11bd"],["\ub873","\u1105\u1169\u11be"],["\ub874","\u1105\u1169\u11bf"],["\ub875","\u1105\u1169\u11c0"],["\ub876","\u1105\u1169\u11c1"],["\ub877","\u1105\u1169\u11c2"],["\ub878","\u1105\u116a"],["\ub879","\u1105\u116a\u11a8"],["\ub87a","\u1105\u116a\u11a9"],["\ub87b","\u1105\u116a\u11aa"],["\ub87c","\u1105\u116a\u11ab"],["\ub87d","\u1105\u116a\u11ac"],["\ub87e","\u1105\u116a\u11ad"],["\ub87f","\u1105\u116a\u11ae"],["\ub880","\u1105\u116a\u11af"],["\ub881","\u1105\u116a\u11b0"],["\ub882","\u1105\u116a\u11b1"],["\ub883","\u1105\u116a\u11b2"],["\ub884","\u1105\u116a\u11b3"],["\ub885","\u1105\u116a\u11b4"],["\ub886","\u1105\u116a\u11b5"],["\ub887","\u1105\u116a\u11b6"],["\ub888","\u1105\u116a\u11b7"],["\ub889","\u1105\u116a\u11b8"],["\ub88a","\u1105\u116a\u11b9"],["\ub88b","\u1105\u116a\u11ba"],["\ub88c","\u1105\u116a\u11bb"],["\ub88d","\u1105\u116a\u11bc"],["\ub88e","\u1105\u116a\u11bd"],["\ub88f","\u1105\u116a\u11be"],["\ub890","\u1105\u116a\u11bf"],["\ub891","\u1105\u116a\u11c0"],["\ub892","\u1105\u116a\u11c1"],["\ub893","\u1105\u116a\u11c2"],["\ub894","\u1105\u116b"],["\ub895","\u1105\u116b\u11a8"],["\ub896","\u1105\u116b\u11a9"],["\ub897","\u1105\u116b\u11aa"],["\ub898","\u1105\u116b\u11ab"],["\ub899","\u1105\u116b\u11ac"],["\ub89a","\u1105\u116b\u11ad"],["\ub89b","\u1105\u116b\u11ae"],["\ub89c","\u1105\u116b\u11af"],["\ub89d","\u1105\u116b\u11b0"],["\ub89e","\u1105\u116b\u11b1"],["\ub89f","\u1105\u116b\u11b2"],["\ub8a0","\u1105\u116b\u11b3"],["\ub8a1","\u1105\u116b\u11b4"],["\ub8a2","\u1105\u116b\u11b5"],["\ub8a3","\u1105\u116b\u11b6"],["\ub8a4","\u1105\u116b\u11b7"],["\ub8a5","\u1105\u116b\u11b8"],["\ub8a6","\u1105\u116b\u11b9"],["\ub8a7","\u1105\u116b\u11ba"],["\ub8a8","\u1105\u116b\u11bb"],["\ub8a9","\u1105\u116b\u11bc"],["\ub8aa","\u1105\u116b\u11bd"],["\ub8ab","\u1105\u116b\u11be"],["\ub8ac","\u1105\u116b\u11bf"],["\ub8ad","\u1105\u116b\u11c0"],["\ub8ae","\u1105\u116b\u11c1"],["\ub8af","\u1105\u116b\u11c2"],["\ub8b0","\u1105\u116c"],["\ub8b1","\u1105\u116c\u11a8"],["\ub8b2","\u1105\u116c\u11a9"],["\ub8b3","\u1105\u116c\u11aa"],["\ub8b4","\u1105\u116c\u11ab"],["\ub8b5","\u1105\u116c\u11ac"],["\ub8b6","\u1105\u116c\u11ad"],["\ub8b7","\u1105\u116c\u11ae"],["\ub8b8","\u1105\u116c\u11af"],["\ub8b9","\u1105\u116c\u11b0"],["\ub8ba","\u1105\u116c\u11b1"],["\ub8bb","\u1105\u116c\u11b2"],["\ub8bc","\u1105\u116c\u11b3"],["\ub8bd","\u1105\u116c\u11b4"],["\ub8be","\u1105\u116c\u11b5"],["\ub8bf","\u1105\u116c\u11b6"],["\ub8c0","\u1105\u116c\u11b7"],["\ub8c1","\u1105\u116c\u11b8"],["\ub8c2","\u1105\u116c\u11b9"],["\ub8c3","\u1105\u116c\u11ba"],["\ub8c4","\u1105\u116c\u11bb"],["\ub8c5","\u1105\u116c\u11bc"],["\ub8c6","\u1105\u116c\u11bd"],["\ub8c7","\u1105\u116c\u11be"],["\ub8c8","\u1105\u116c\u11bf"],["\ub8c9","\u1105\u116c\u11c0"],["\ub8ca","\u1105\u116c\u11c1"],["\ub8cb","\u1105\u116c\u11c2"],["\ub8cc","\u1105\u116d"],["\ub8cd","\u1105\u116d\u11a8"],["\ub8ce","\u1105\u116d\u11a9"],["\ub8cf","\u1105\u116d\u11aa"],["\ub8d0","\u1105\u116d\u11ab"],["\ub8d1","\u1105\u116d\u11ac"],["\ub8d2","\u1105\u116d\u11ad"],["\ub8d3","\u1105\u116d\u11ae"],["\ub8d4","\u1105\u116d\u11af"],["\ub8d5","\u1105\u116d\u11b0"],["\ub8d6","\u1105\u116d\u11b1"],["\ub8d7","\u1105\u116d\u11b2"],["\ub8d8","\u1105\u116d\u11b3"],["\ub8d9","\u1105\u116d\u11b4"],["\ub8da","\u1105\u116d\u11b5"],["\ub8db","\u1105\u116d\u11b6"],["\ub8dc","\u1105\u116d\u11b7"],["\ub8dd","\u1105\u116d\u11b8"],["\ub8de","\u1105\u116d\u11b9"],["\ub8df","\u1105\u116d\u11ba"],["\ub8e0","\u1105\u116d\u11bb"],["\ub8e1","\u1105\u116d\u11bc"],["\ub8e2","\u1105\u116d\u11bd"],["\ub8e3","\u1105\u116d\u11be"],["\ub8e4","\u1105\u116d\u11bf"],["\ub8e5","\u1105\u116d\u11c0"],["\ub8e6","\u1105\u116d\u11c1"],["\ub8e7","\u1105\u116d\u11c2"],["\ub8e8","\u1105\u116e"],["\ub8e9","\u1105\u116e\u11a8"],["\ub8ea","\u1105\u116e\u11a9"],["\ub8eb","\u1105\u116e\u11aa"],["\ub8ec","\u1105\u116e\u11ab"],["\ub8ed","\u1105\u116e\u11ac"],["\ub8ee","\u1105\u116e\u11ad"],["\ub8ef","\u1105\u116e\u11ae"],["\ub8f0","\u1105\u116e\u11af"],["\ub8f1","\u1105\u116e\u11b0"],["\ub8f2","\u1105\u116e\u11b1"],["\ub8f3","\u1105\u116e\u11b2"],["\ub8f4","\u1105\u116e\u11b3"],["\ub8f5","\u1105\u116e\u11b4"],["\ub8f6","\u1105\u116e\u11b5"],["\ub8f7","\u1105\u116e\u11b6"],["\ub8f8","\u1105\u116e\u11b7"],["\ub8f9","\u1105\u116e\u11b8"],["\ub8fa","\u1105\u116e\u11b9"],["\ub8fb","\u1105\u116e\u11ba"],["\ub8fc","\u1105\u116e\u11bb"],["\ub8fd","\u1105\u116e\u11bc"],["\ub8fe","\u1105\u116e\u11bd"],["\ub8ff","\u1105\u116e\u11be"],["\ub900","\u1105\u116e\u11bf"],["\ub901","\u1105\u116e\u11c0"],["\ub902","\u1105\u116e\u11c1"],["\ub903","\u1105\u116e\u11c2"],["\ub904","\u1105\u116f"],["\ub905","\u1105\u116f\u11a8"],["\ub906","\u1105\u116f\u11a9"],["\ub907","\u1105\u116f\u11aa"],["\ub908","\u1105\u116f\u11ab"],["\ub909","\u1105\u116f\u11ac"],["\ub90a","\u1105\u116f\u11ad"],["\ub90b","\u1105\u116f\u11ae"],["\ub90c","\u1105\u116f\u11af"],["\ub90d","\u1105\u116f\u11b0"],["\ub90e","\u1105\u116f\u11b1"],["\ub90f","\u1105\u116f\u11b2"],["\ub910","\u1105\u116f\u11b3"],["\ub911","\u1105\u116f\u11b4"],["\ub912","\u1105\u116f\u11b5"],["\ub913","\u1105\u116f\u11b6"],["\ub914","\u1105\u116f\u11b7"],["\ub915","\u1105\u116f\u11b8"],["\ub916","\u1105\u116f\u11b9"],["\ub917","\u1105\u116f\u11ba"],["\ub918","\u1105\u116f\u11bb"],["\ub919","\u1105\u116f\u11bc"],["\ub91a","\u1105\u116f\u11bd"],["\ub91b","\u1105\u116f\u11be"],["\ub91c","\u1105\u116f\u11bf"],["\ub91d","\u1105\u116f\u11c0"],["\ub91e","\u1105\u116f\u11c1"],["\ub91f","\u1105\u116f\u11c2"],["\ub920","\u1105\u1170"],["\ub921","\u1105\u1170\u11a8"],["\ub922","\u1105\u1170\u11a9"],["\ub923","\u1105\u1170\u11aa"],["\ub924","\u1105\u1170\u11ab"],["\ub925","\u1105\u1170\u11ac"],["\ub926","\u1105\u1170\u11ad"],["\ub927","\u1105\u1170\u11ae"],["\ub928","\u1105\u1170\u11af"],["\ub929","\u1105\u1170\u11b0"],["\ub92a","\u1105\u1170\u11b1"],["\ub92b","\u1105\u1170\u11b2"],["\ub92c","\u1105\u1170\u11b3"],["\ub92d","\u1105\u1170\u11b4"],["\ub92e","\u1105\u1170\u11b5"],["\ub92f","\u1105\u1170\u11b6"],["\ub930","\u1105\u1170\u11b7"],["\ub931","\u1105\u1170\u11b8"],["\ub932","\u1105\u1170\u11b9"],["\ub933","\u1105\u1170\u11ba"],["\ub934","\u1105\u1170\u11bb"],["\ub935","\u1105\u1170\u11bc"],["\ub936","\u1105\u1170\u11bd"],["\ub937","\u1105\u1170\u11be"],["\ub938","\u1105\u1170\u11bf"],["\ub939","\u1105\u1170\u11c0"],["\ub93a","\u1105\u1170\u11c1"],["\ub93b","\u1105\u1170\u11c2"],["\ub93c","\u1105\u1171"],["\ub93d","\u1105\u1171\u11a8"],["\ub93e","\u1105\u1171\u11a9"],["\ub93f","\u1105\u1171\u11aa"],["\ub940","\u1105\u1171\u11ab"],["\ub941","\u1105\u1171\u11ac"],["\ub942","\u1105\u1171\u11ad"],["\ub943","\u1105\u1171\u11ae"],["\ub944","\u1105\u1171\u11af"],["\ub945","\u1105\u1171\u11b0"],["\ub946","\u1105\u1171\u11b1"],["\ub947","\u1105\u1171\u11b2"],["\ub948","\u1105\u1171\u11b3"],["\ub949","\u1105\u1171\u11b4"],["\ub94a","\u1105\u1171\u11b5"],["\ub94b","\u1105\u1171\u11b6"],["\ub94c","\u1105\u1171\u11b7"],["\ub94d","\u1105\u1171\u11b8"],["\ub94e","\u1105\u1171\u11b9"],["\ub94f","\u1105\u1171\u11ba"],["\ub950","\u1105\u1171\u11bb"],["\ub951","\u1105\u1171\u11bc"],["\ub952","\u1105\u1171\u11bd"],["\ub953","\u1105\u1171\u11be"],["\ub954","\u1105\u1171\u11bf"],["\ub955","\u1105\u1171\u11c0"],["\ub956","\u1105\u1171\u11c1"],["\ub957","\u1105\u1171\u11c2"],["\ub958","\u1105\u1172"],["\ub959","\u1105\u1172\u11a8"],["\ub95a","\u1105\u1172\u11a9"],["\ub95b","\u1105\u1172\u11aa"],["\ub95c","\u1105\u1172\u11ab"],["\ub95d","\u1105\u1172\u11ac"],["\ub95e","\u1105\u1172\u11ad"],["\ub95f","\u1105\u1172\u11ae"],["\ub960","\u1105\u1172\u11af"],["\ub961","\u1105\u1172\u11b0"],["\ub962","\u1105\u1172\u11b1"],["\ub963","\u1105\u1172\u11b2"],["\ub964","\u1105\u1172\u11b3"],["\ub965","\u1105\u1172\u11b4"],["\ub966","\u1105\u1172\u11b5"],["\ub967","\u1105\u1172\u11b6"],["\ub968","\u1105\u1172\u11b7"],["\ub969","\u1105\u1172\u11b8"],["\ub96a","\u1105\u1172\u11b9"],["\ub96b","\u1105\u1172\u11ba"],["\ub96c","\u1105\u1172\u11bb"],["\ub96d","\u1105\u1172\u11bc"],["\ub96e","\u1105\u1172\u11bd"],["\ub96f","\u1105\u1172\u11be"],["\ub970","\u1105\u1172\u11bf"],["\ub971","\u1105\u1172\u11c0"],["\ub972","\u1105\u1172\u11c1"],["\ub973","\u1105\u1172\u11c2"],["\ub974","\u1105\u1173"],["\ub975","\u1105\u1173\u11a8"],["\ub976","\u1105\u1173\u11a9"],["\ub977","\u1105\u1173\u11aa"],["\ub978","\u1105\u1173\u11ab"],["\ub979","\u1105\u1173\u11ac"],["\ub97a","\u1105\u1173\u11ad"],["\ub97b","\u1105\u1173\u11ae"],["\ub97c","\u1105\u1173\u11af"],["\ub97d","\u1105\u1173\u11b0"],["\ub97e","\u1105\u1173\u11b1"],["\ub97f","\u1105\u1173\u11b2"],["\ub980","\u1105\u1173\u11b3"],["\ub981","\u1105\u1173\u11b4"],["\ub982","\u1105\u1173\u11b5"],["\ub983","\u1105\u1173\u11b6"],["\ub984","\u1105\u1173\u11b7"],["\ub985","\u1105\u1173\u11b8"],["\ub986","\u1105\u1173\u11b9"],["\ub987","\u1105\u1173\u11ba"],["\ub988","\u1105\u1173\u11bb"],["\ub989","\u1105\u1173\u11bc"],["\ub98a","\u1105\u1173\u11bd"],["\ub98b","\u1105\u1173\u11be"],["\ub98c","\u1105\u1173\u11bf"],["\ub98d","\u1105\u1173\u11c0"],["\ub98e","\u1105\u1173\u11c1"],["\ub98f","\u1105\u1173\u11c2"],["\ub990","\u1105\u1174"],["\ub991","\u1105\u1174\u11a8"],["\ub992","\u1105\u1174\u11a9"],["\ub993","\u1105\u1174\u11aa"],["\ub994","\u1105\u1174\u11ab"],["\ub995","\u1105\u1174\u11ac"],["\ub996","\u1105\u1174\u11ad"],["\ub997","\u1105\u1174\u11ae"],["\ub998","\u1105\u1174\u11af"],["\ub999","\u1105\u1174\u11b0"],["\ub99a","\u1105\u1174\u11b1"],["\ub99b","\u1105\u1174\u11b2"],["\ub99c","\u1105\u1174\u11b3"],["\ub99d","\u1105\u1174\u11b4"],["\ub99e","\u1105\u1174\u11b5"],["\ub99f","\u1105\u1174\u11b6"],["\ub9a0","\u1105\u1174\u11b7"],["\ub9a1","\u1105\u1174\u11b8"],["\ub9a2","\u1105\u1174\u11b9"],["\ub9a3","\u1105\u1174\u11ba"],["\ub9a4","\u1105\u1174\u11bb"],["\ub9a5","\u1105\u1174\u11bc"],["\ub9a6","\u1105\u1174\u11bd"],["\ub9a7","\u1105\u1174\u11be"],["\ub9a8","\u1105\u1174\u11bf"],["\ub9a9","\u1105\u1174\u11c0"],["\ub9aa","\u1105\u1174\u11c1"],["\ub9ab","\u1105\u1174\u11c2"],["\ub9ac","\u1105\u1175"],["\ub9ad","\u1105\u1175\u11a8"],["\ub9ae","\u1105\u1175\u11a9"],["\ub9af","\u1105\u1175\u11aa"],["\ub9b0","\u1105\u1175\u11ab"],["\ub9b1","\u1105\u1175\u11ac"],["\ub9b2","\u1105\u1175\u11ad"],["\ub9b3","\u1105\u1175\u11ae"],["\ub9b4","\u1105\u1175\u11af"],["\ub9b5","\u1105\u1175\u11b0"],["\ub9b6","\u1105\u1175\u11b1"],["\ub9b7","\u1105\u1175\u11b2"],["\ub9b8","\u1105\u1175\u11b3"],["\ub9b9","\u1105\u1175\u11b4"],["\ub9ba","\u1105\u1175\u11b5"],["\ub9bb","\u1105\u1175\u11b6"],["\ub9bc","\u1105\u1175\u11b7"],["\ub9bd","\u1105\u1175\u11b8"],["\ub9be","\u1105\u1175\u11b9"],["\ub9bf","\u1105\u1175\u11ba"],["\ub9c0","\u1105\u1175\u11bb"],["\ub9c1","\u1105\u1175\u11bc"],["\ub9c2","\u1105\u1175\u11bd"],["\ub9c3","\u1105\u1175\u11be"],["\ub9c4","\u1105\u1175\u11bf"],["\ub9c5","\u1105\u1175\u11c0"],["\ub9c6","\u1105\u1175\u11c1"],["\ub9c7","\u1105\u1175\u11c2"],["\ub9c8","\u1106\u1161"],["\ub9c9","\u1106\u1161\u11a8"],["\ub9ca","\u1106\u1161\u11a9"],["\ub9cb","\u1106\u1161\u11aa"],["\ub9cc","\u1106\u1161\u11ab"],["\ub9cd","\u1106\u1161\u11ac"],["\ub9ce","\u1106\u1161\u11ad"],["\ub9cf","\u1106\u1161\u11ae"],["\ub9d0","\u1106\u1161\u11af"],["\ub9d1","\u1106\u1161\u11b0"],["\ub9d2","\u1106\u1161\u11b1"],["\ub9d3","\u1106\u1161\u11b2"],["\ub9d4","\u1106\u1161\u11b3"],["\ub9d5","\u1106\u1161\u11b4"],["\ub9d6","\u1106\u1161\u11b5"],["\ub9d7","\u1106\u1161\u11b6"],["\ub9d8","\u1106\u1161\u11b7"],["\ub9d9","\u1106\u1161\u11b8"],["\ub9da","\u1106\u1161\u11b9"],["\ub9db","\u1106\u1161\u11ba"],["\ub9dc","\u1106\u1161\u11bb"],["\ub9dd","\u1106\u1161\u11bc"],["\ub9de","\u1106\u1161\u11bd"],["\ub9df","\u1106\u1161\u11be"],["\ub9e0","\u1106\u1161\u11bf"],["\ub9e1","\u1106\u1161\u11c0"],["\ub9e2","\u1106\u1161\u11c1"],["\ub9e3","\u1106\u1161\u11c2"],["\ub9e4","\u1106\u1162"],["\ub9e5","\u1106\u1162\u11a8"],["\ub9e6","\u1106\u1162\u11a9"],["\ub9e7","\u1106\u1162\u11aa"],["\ub9e8","\u1106\u1162\u11ab"],["\ub9e9","\u1106\u1162\u11ac"],["\ub9ea","\u1106\u1162\u11ad"],["\ub9eb","\u1106\u1162\u11ae"],["\ub9ec","\u1106\u1162\u11af"],["\ub9ed","\u1106\u1162\u11b0"],["\ub9ee","\u1106\u1162\u11b1"],["\ub9ef","\u1106\u1162\u11b2"],["\ub9f0","\u1106\u1162\u11b3"],["\ub9f1","\u1106\u1162\u11b4"],["\ub9f2","\u1106\u1162\u11b5"],["\ub9f3","\u1106\u1162\u11b6"],["\ub9f4","\u1106\u1162\u11b7"],["\ub9f5","\u1106\u1162\u11b8"],["\ub9f6","\u1106\u1162\u11b9"],["\ub9f7","\u1106\u1162\u11ba"],["\ub9f8","\u1106\u1162\u11bb"],["\ub9f9","\u1106\u1162\u11bc"],["\ub9fa","\u1106\u1162\u11bd"],["\ub9fb","\u1106\u1162\u11be"],["\ub9fc","\u1106\u1162\u11bf"],["\ub9fd","\u1106\u1162\u11c0"],["\ub9fe","\u1106\u1162\u11c1"],["\ub9ff","\u1106\u1162\u11c2"],["\uba00","\u1106\u1163"],["\uba01","\u1106\u1163\u11a8"],["\uba02","\u1106\u1163\u11a9"],["\uba03","\u1106\u1163\u11aa"],["\uba04","\u1106\u1163\u11ab"],["\uba05","\u1106\u1163\u11ac"],["\uba06","\u1106\u1163\u11ad"],["\uba07","\u1106\u1163\u11ae"],["\uba08","\u1106\u1163\u11af"],["\uba09","\u1106\u1163\u11b0"],["\uba0a","\u1106\u1163\u11b1"],["\uba0b","\u1106\u1163\u11b2"],["\uba0c","\u1106\u1163\u11b3"],["\uba0d","\u1106\u1163\u11b4"],["\uba0e","\u1106\u1163\u11b5"],["\uba0f","\u1106\u1163\u11b6"],["\uba10","\u1106\u1163\u11b7"],["\uba11","\u1106\u1163\u11b8"],["\uba12","\u1106\u1163\u11b9"],["\uba13","\u1106\u1163\u11ba"],["\uba14","\u1106\u1163\u11bb"],["\uba15","\u1106\u1163\u11bc"],["\uba16","\u1106\u1163\u11bd"],["\uba17","\u1106\u1163\u11be"],["\uba18","\u1106\u1163\u11bf"],["\uba19","\u1106\u1163\u11c0"],["\uba1a","\u1106\u1163\u11c1"],["\uba1b","\u1106\u1163\u11c2"],["\uba1c","\u1106\u1164"],["\uba1d","\u1106\u1164\u11a8"],["\uba1e","\u1106\u1164\u11a9"],["\uba1f","\u1106\u1164\u11aa"],["\uba20","\u1106\u1164\u11ab"],["\uba21","\u1106\u1164\u11ac"],["\uba22","\u1106\u1164\u11ad"],["\uba23","\u1106\u1164\u11ae"],["\uba24","\u1106\u1164\u11af"],["\uba25","\u1106\u1164\u11b0"],["\uba26","\u1106\u1164\u11b1"],["\uba27","\u1106\u1164\u11b2"],["\uba28","\u1106\u1164\u11b3"],["\uba29","\u1106\u1164\u11b4"],["\uba2a","\u1106\u1164\u11b5"],["\uba2b","\u1106\u1164\u11b6"],["\uba2c","\u1106\u1164\u11b7"],["\uba2d","\u1106\u1164\u11b8"],["\uba2e","\u1106\u1164\u11b9"],["\uba2f","\u1106\u1164\u11ba"],["\uba30","\u1106\u1164\u11bb"],["\uba31","\u1106\u1164\u11bc"],["\uba32","\u1106\u1164\u11bd"],["\uba33","\u1106\u1164\u11be"],["\uba34","\u1106\u1164\u11bf"],["\uba35","\u1106\u1164\u11c0"],["\uba36","\u1106\u1164\u11c1"],["\uba37","\u1106\u1164\u11c2"],["\uba38","\u1106\u1165"],["\uba39","\u1106\u1165\u11a8"],["\uba3a","\u1106\u1165\u11a9"],["\uba3b","\u1106\u1165\u11aa"],["\uba3c","\u1106\u1165\u11ab"],["\uba3d","\u1106\u1165\u11ac"],["\uba3e","\u1106\u1165\u11ad"],["\uba3f","\u1106\u1165\u11ae"],["\uba40","\u1106\u1165\u11af"],["\uba41","\u1106\u1165\u11b0"],["\uba42","\u1106\u1165\u11b1"],["\uba43","\u1106\u1165\u11b2"],["\uba44","\u1106\u1165\u11b3"],["\uba45","\u1106\u1165\u11b4"],["\uba46","\u1106\u1165\u11b5"],["\uba47","\u1106\u1165\u11b6"],["\uba48","\u1106\u1165\u11b7"],["\uba49","\u1106\u1165\u11b8"],["\uba4a","\u1106\u1165\u11b9"],["\uba4b","\u1106\u1165\u11ba"],["\uba4c","\u1106\u1165\u11bb"],["\uba4d","\u1106\u1165\u11bc"],["\uba4e","\u1106\u1165\u11bd"],["\uba4f","\u1106\u1165\u11be"],["\uba50","\u1106\u1165\u11bf"],["\uba51","\u1106\u1165\u11c0"],["\uba52","\u1106\u1165\u11c1"],["\uba53","\u1106\u1165\u11c2"],["\uba54","\u1106\u1166"],["\uba55","\u1106\u1166\u11a8"],["\uba56","\u1106\u1166\u11a9"],["\uba57","\u1106\u1166\u11aa"],["\uba58","\u1106\u1166\u11ab"],["\uba59","\u1106\u1166\u11ac"],["\uba5a","\u1106\u1166\u11ad"],["\uba5b","\u1106\u1166\u11ae"],["\uba5c","\u1106\u1166\u11af"],["\uba5d","\u1106\u1166\u11b0"],["\uba5e","\u1106\u1166\u11b1"],["\uba5f","\u1106\u1166\u11b2"],["\uba60","\u1106\u1166\u11b3"],["\uba61","\u1106\u1166\u11b4"],["\uba62","\u1106\u1166\u11b5"],["\uba63","\u1106\u1166\u11b6"],["\uba64","\u1106\u1166\u11b7"],["\uba65","\u1106\u1166\u11b8"],["\uba66","\u1106\u1166\u11b9"],["\uba67","\u1106\u1166\u11ba"],["\uba68","\u1106\u1166\u11bb"],["\uba69","\u1106\u1166\u11bc"],["\uba6a","\u1106\u1166\u11bd"],["\uba6b","\u1106\u1166\u11be"],["\uba6c","\u1106\u1166\u11bf"],["\uba6d","\u1106\u1166\u11c0"],["\uba6e","\u1106\u1166\u11c1"],["\uba6f","\u1106\u1166\u11c2"],["\uba70","\u1106\u1167"],["\uba71","\u1106\u1167\u11a8"],["\uba72","\u1106\u1167\u11a9"],["\uba73","\u1106\u1167\u11aa"],["\uba74","\u1106\u1167\u11ab"],["\uba75","\u1106\u1167\u11ac"],["\uba76","\u1106\u1167\u11ad"],["\uba77","\u1106\u1167\u11ae"],["\uba78","\u1106\u1167\u11af"],["\uba79","\u1106\u1167\u11b0"],["\uba7a","\u1106\u1167\u11b1"],["\uba7b","\u1106\u1167\u11b2"],["\uba7c","\u1106\u1167\u11b3"],["\uba7d","\u1106\u1167\u11b4"],["\uba7e","\u1106\u1167\u11b5"],["\uba7f","\u1106\u1167\u11b6"],["\uba80","\u1106\u1167\u11b7"],["\uba81","\u1106\u1167\u11b8"],["\uba82","\u1106\u1167\u11b9"],["\uba83","\u1106\u1167\u11ba"],["\uba84","\u1106\u1167\u11bb"],["\uba85","\u1106\u1167\u11bc"],["\uba86","\u1106\u1167\u11bd"],["\uba87","\u1106\u1167\u11be"],["\uba88","\u1106\u1167\u11bf"],["\uba89","\u1106\u1167\u11c0"],["\uba8a","\u1106\u1167\u11c1"],["\uba8b","\u1106\u1167\u11c2"],["\uba8c","\u1106\u1168"],["\uba8d","\u1106\u1168\u11a8"],["\uba8e","\u1106\u1168\u11a9"],["\uba8f","\u1106\u1168\u11aa"],["\uba90","\u1106\u1168\u11ab"],["\uba91","\u1106\u1168\u11ac"],["\uba92","\u1106\u1168\u11ad"],["\uba93","\u1106\u1168\u11ae"],["\uba94","\u1106\u1168\u11af"],["\uba95","\u1106\u1168\u11b0"],["\uba96","\u1106\u1168\u11b1"],["\uba97","\u1106\u1168\u11b2"],["\uba98","\u1106\u1168\u11b3"],["\uba99","\u1106\u1168\u11b4"],["\uba9a","\u1106\u1168\u11b5"],["\uba9b","\u1106\u1168\u11b6"],["\uba9c","\u1106\u1168\u11b7"],["\uba9d","\u1106\u1168\u11b8"],["\uba9e","\u1106\u1168\u11b9"],["\uba9f","\u1106\u1168\u11ba"],["\ubaa0","\u1106\u1168\u11bb"],["\ubaa1","\u1106\u1168\u11bc"],["\ubaa2","\u1106\u1168\u11bd"],["\ubaa3","\u1106\u1168\u11be"],["\ubaa4","\u1106\u1168\u11bf"],["\ubaa5","\u1106\u1168\u11c0"],["\ubaa6","\u1106\u1168\u11c1"],["\ubaa7","\u1106\u1168\u11c2"],["\ubaa8","\u1106\u1169"],["\ubaa9","\u1106\u1169\u11a8"],["\ubaaa","\u1106\u1169\u11a9"],["\ubaab","\u1106\u1169\u11aa"],["\ubaac","\u1106\u1169\u11ab"],["\ubaad","\u1106\u1169\u11ac"],["\ubaae","\u1106\u1169\u11ad"],["\ubaaf","\u1106\u1169\u11ae"],["\ubab0","\u1106\u1169\u11af"],["\ubab1","\u1106\u1169\u11b0"],["\ubab2","\u1106\u1169\u11b1"],["\ubab3","\u1106\u1169\u11b2"],["\ubab4","\u1106\u1169\u11b3"],["\ubab5","\u1106\u1169\u11b4"],["\ubab6","\u1106\u1169\u11b5"],["\ubab7","\u1106\u1169\u11b6"],["\ubab8","\u1106\u1169\u11b7"],["\ubab9","\u1106\u1169\u11b8"],["\ubaba","\u1106\u1169\u11b9"],["\ubabb","\u1106\u1169\u11ba"],["\ubabc","\u1106\u1169\u11bb"],["\ubabd","\u1106\u1169\u11bc"],["\ubabe","\u1106\u1169\u11bd"],["\ubabf","\u1106\u1169\u11be"],["\ubac0","\u1106\u1169\u11bf"],["\ubac1","\u1106\u1169\u11c0"],["\ubac2","\u1106\u1169\u11c1"],["\ubac3","\u1106\u1169\u11c2"],["\ubac4","\u1106\u116a"],["\ubac5","\u1106\u116a\u11a8"],["\ubac6","\u1106\u116a\u11a9"],["\ubac7","\u1106\u116a\u11aa"],["\ubac8","\u1106\u116a\u11ab"],["\ubac9","\u1106\u116a\u11ac"],["\ubaca","\u1106\u116a\u11ad"],["\ubacb","\u1106\u116a\u11ae"],["\ubacc","\u1106\u116a\u11af"],["\ubacd","\u1106\u116a\u11b0"],["\ubace","\u1106\u116a\u11b1"],["\ubacf","\u1106\u116a\u11b2"],["\ubad0","\u1106\u116a\u11b3"],["\ubad1","\u1106\u116a\u11b4"],["\ubad2","\u1106\u116a\u11b5"],["\ubad3","\u1106\u116a\u11b6"],["\ubad4","\u1106\u116a\u11b7"],["\ubad5","\u1106\u116a\u11b8"],["\ubad6","\u1106\u116a\u11b9"],["\ubad7","\u1106\u116a\u11ba"],["\ubad8","\u1106\u116a\u11bb"],["\ubad9","\u1106\u116a\u11bc"],["\ubada","\u1106\u116a\u11bd"],["\ubadb","\u1106\u116a\u11be"],["\ubadc","\u1106\u116a\u11bf"],["\ubadd","\u1106\u116a\u11c0"],["\ubade","\u1106\u116a\u11c1"],["\ubadf","\u1106\u116a\u11c2"],["\ubae0","\u1106\u116b"],["\ubae1","\u1106\u116b\u11a8"],["\ubae2","\u1106\u116b\u11a9"],["\ubae3","\u1106\u116b\u11aa"],["\ubae4","\u1106\u116b\u11ab"],["\ubae5","\u1106\u116b\u11ac"],["\ubae6","\u1106\u116b\u11ad"],["\ubae7","\u1106\u116b\u11ae"],["\ubae8","\u1106\u116b\u11af"],["\ubae9","\u1106\u116b\u11b0"],["\ubaea","\u1106\u116b\u11b1"],["\ubaeb","\u1106\u116b\u11b2"],["\ubaec","\u1106\u116b\u11b3"],["\ubaed","\u1106\u116b\u11b4"],["\ubaee","\u1106\u116b\u11b5"],["\ubaef","\u1106\u116b\u11b6"],["\ubaf0","\u1106\u116b\u11b7"],["\ubaf1","\u1106\u116b\u11b8"],["\ubaf2","\u1106\u116b\u11b9"],["\ubaf3","\u1106\u116b\u11ba"],["\ubaf4","\u1106\u116b\u11bb"],["\ubaf5","\u1106\u116b\u11bc"],["\ubaf6","\u1106\u116b\u11bd"],["\ubaf7","\u1106\u116b\u11be"],["\ubaf8","\u1106\u116b\u11bf"],["\ubaf9","\u1106\u116b\u11c0"],["\ubafa","\u1106\u116b\u11c1"],["\ubafb","\u1106\u116b\u11c2"],["\ubafc","\u1106\u116c"],["\ubafd","\u1106\u116c\u11a8"],["\ubafe","\u1106\u116c\u11a9"],["\ubaff","\u1106\u116c\u11aa"],["\ubb00","\u1106\u116c\u11ab"],["\ubb01","\u1106\u116c\u11ac"],["\ubb02","\u1106\u116c\u11ad"],["\ubb03","\u1106\u116c\u11ae"],["\ubb04","\u1106\u116c\u11af"],["\ubb05","\u1106\u116c\u11b0"],["\ubb06","\u1106\u116c\u11b1"],["\ubb07","\u1106\u116c\u11b2"],["\ubb08","\u1106\u116c\u11b3"],["\ubb09","\u1106\u116c\u11b4"],["\ubb0a","\u1106\u116c\u11b5"],["\ubb0b","\u1106\u116c\u11b6"],["\ubb0c","\u1106\u116c\u11b7"],["\ubb0d","\u1106\u116c\u11b8"],["\ubb0e","\u1106\u116c\u11b9"],["\ubb0f","\u1106\u116c\u11ba"],["\ubb10","\u1106\u116c\u11bb"],["\ubb11","\u1106\u116c\u11bc"],["\ubb12","\u1106\u116c\u11bd"],["\ubb13","\u1106\u116c\u11be"],["\ubb14","\u1106\u116c\u11bf"],["\ubb15","\u1106\u116c\u11c0"],["\ubb16","\u1106\u116c\u11c1"],["\ubb17","\u1106\u116c\u11c2"],["\ubb18","\u1106\u116d"],["\ubb19","\u1106\u116d\u11a8"],["\ubb1a","\u1106\u116d\u11a9"],["\ubb1b","\u1106\u116d\u11aa"],["\ubb1c","\u1106\u116d\u11ab"],["\ubb1d","\u1106\u116d\u11ac"],["\ubb1e","\u1106\u116d\u11ad"],["\ubb1f","\u1106\u116d\u11ae"],["\ubb20","\u1106\u116d\u11af"],["\ubb21","\u1106\u116d\u11b0"],["\ubb22","\u1106\u116d\u11b1"],["\ubb23","\u1106\u116d\u11b2"],["\ubb24","\u1106\u116d\u11b3"],["\ubb25","\u1106\u116d\u11b4"],["\ubb26","\u1106\u116d\u11b5"],["\ubb27","\u1106\u116d\u11b6"],["\ubb28","\u1106\u116d\u11b7"],["\ubb29","\u1106\u116d\u11b8"],["\ubb2a","\u1106\u116d\u11b9"],["\ubb2b","\u1106\u116d\u11ba"],["\ubb2c","\u1106\u116d\u11bb"],["\ubb2d","\u1106\u116d\u11bc"],["\ubb2e","\u1106\u116d\u11bd"],["\ubb2f","\u1106\u116d\u11be"],["\ubb30","\u1106\u116d\u11bf"],["\ubb31","\u1106\u116d\u11c0"],["\ubb32","\u1106\u116d\u11c1"],["\ubb33","\u1106\u116d\u11c2"],["\ubb34","\u1106\u116e"],["\ubb35","\u1106\u116e\u11a8"],["\ubb36","\u1106\u116e\u11a9"],["\ubb37","\u1106\u116e\u11aa"],["\ubb38","\u1106\u116e\u11ab"],["\ubb39","\u1106\u116e\u11ac"],["\ubb3a","\u1106\u116e\u11ad"],["\ubb3b","\u1106\u116e\u11ae"],["\ubb3c","\u1106\u116e\u11af"],["\ubb3d","\u1106\u116e\u11b0"],["\ubb3e","\u1106\u116e\u11b1"],["\ubb3f","\u1106\u116e\u11b2"],["\ubb40","\u1106\u116e\u11b3"],["\ubb41","\u1106\u116e\u11b4"],["\ubb42","\u1106\u116e\u11b5"],["\ubb43","\u1106\u116e\u11b6"],["\ubb44","\u1106\u116e\u11b7"],["\ubb45","\u1106\u116e\u11b8"],["\ubb46","\u1106\u116e\u11b9"],["\ubb47","\u1106\u116e\u11ba"],["\ubb48","\u1106\u116e\u11bb"],["\ubb49","\u1106\u116e\u11bc"],["\ubb4a","\u1106\u116e\u11bd"],["\ubb4b","\u1106\u116e\u11be"],["\ubb4c","\u1106\u116e\u11bf"],["\ubb4d","\u1106\u116e\u11c0"],["\ubb4e","\u1106\u116e\u11c1"],["\ubb4f","\u1106\u116e\u11c2"],["\ubb50","\u1106\u116f"],["\ubb51","\u1106\u116f\u11a8"],["\ubb52","\u1106\u116f\u11a9"],["\ubb53","\u1106\u116f\u11aa"],["\ubb54","\u1106\u116f\u11ab"],["\ubb55","\u1106\u116f\u11ac"],["\ubb56","\u1106\u116f\u11ad"],["\ubb57","\u1106\u116f\u11ae"],["\ubb58","\u1106\u116f\u11af"],["\ubb59","\u1106\u116f\u11b0"],["\ubb5a","\u1106\u116f\u11b1"],["\ubb5b","\u1106\u116f\u11b2"],["\ubb5c","\u1106\u116f\u11b3"],["\ubb5d","\u1106\u116f\u11b4"],["\ubb5e","\u1106\u116f\u11b5"],["\ubb5f","\u1106\u116f\u11b6"],["\ubb60","\u1106\u116f\u11b7"],["\ubb61","\u1106\u116f\u11b8"],["\ubb62","\u1106\u116f\u11b9"],["\ubb63","\u1106\u116f\u11ba"],["\ubb64","\u1106\u116f\u11bb"],["\ubb65","\u1106\u116f\u11bc"],["\ubb66","\u1106\u116f\u11bd"],["\ubb67","\u1106\u116f\u11be"],["\ubb68","\u1106\u116f\u11bf"],["\ubb69","\u1106\u116f\u11c0"],["\ubb6a","\u1106\u116f\u11c1"],["\ubb6b","\u1106\u116f\u11c2"],["\ubb6c","\u1106\u1170"],["\ubb6d","\u1106\u1170\u11a8"],["\ubb6e","\u1106\u1170\u11a9"],["\ubb6f","\u1106\u1170\u11aa"],["\ubb70","\u1106\u1170\u11ab"],["\ubb71","\u1106\u1170\u11ac"],["\ubb72","\u1106\u1170\u11ad"],["\ubb73","\u1106\u1170\u11ae"],["\ubb74","\u1106\u1170\u11af"],["\ubb75","\u1106\u1170\u11b0"],["\ubb76","\u1106\u1170\u11b1"],["\ubb77","\u1106\u1170\u11b2"],["\ubb78","\u1106\u1170\u11b3"],["\ubb79","\u1106\u1170\u11b4"],["\ubb7a","\u1106\u1170\u11b5"],["\ubb7b","\u1106\u1170\u11b6"],["\ubb7c","\u1106\u1170\u11b7"],["\ubb7d","\u1106\u1170\u11b8"],["\ubb7e","\u1106\u1170\u11b9"],["\ubb7f","\u1106\u1170\u11ba"],["\ubb80","\u1106\u1170\u11bb"],["\ubb81","\u1106\u1170\u11bc"],["\ubb82","\u1106\u1170\u11bd"],["\ubb83","\u1106\u1170\u11be"],["\ubb84","\u1106\u1170\u11bf"],["\ubb85","\u1106\u1170\u11c0"],["\ubb86","\u1106\u1170\u11c1"],["\ubb87","\u1106\u1170\u11c2"],["\ubb88","\u1106\u1171"],["\ubb89","\u1106\u1171\u11a8"],["\ubb8a","\u1106\u1171\u11a9"],["\ubb8b","\u1106\u1171\u11aa"],["\ubb8c","\u1106\u1171\u11ab"],["\ubb8d","\u1106\u1171\u11ac"],["\ubb8e","\u1106\u1171\u11ad"],["\ubb8f","\u1106\u1171\u11ae"],["\ubb90","\u1106\u1171\u11af"],["\ubb91","\u1106\u1171\u11b0"],["\ubb92","\u1106\u1171\u11b1"],["\ubb93","\u1106\u1171\u11b2"],["\ubb94","\u1106\u1171\u11b3"],["\ubb95","\u1106\u1171\u11b4"],["\ubb96","\u1106\u1171\u11b5"],["\ubb97","\u1106\u1171\u11b6"],["\ubb98","\u1106\u1171\u11b7"],["\ubb99","\u1106\u1171\u11b8"],["\ubb9a","\u1106\u1171\u11b9"],["\ubb9b","\u1106\u1171\u11ba"],["\ubb9c","\u1106\u1171\u11bb"],["\ubb9d","\u1106\u1171\u11bc"],["\ubb9e","\u1106\u1171\u11bd"],["\ubb9f","\u1106\u1171\u11be"],["\ubba0","\u1106\u1171\u11bf"],["\ubba1","\u1106\u1171\u11c0"],["\ubba2","\u1106\u1171\u11c1"],["\ubba3","\u1106\u1171\u11c2"],["\ubba4","\u1106\u1172"],["\ubba5","\u1106\u1172\u11a8"],["\ubba6","\u1106\u1172\u11a9"],["\ubba7","\u1106\u1172\u11aa"],["\ubba8","\u1106\u1172\u11ab"],["\ubba9","\u1106\u1172\u11ac"],["\ubbaa","\u1106\u1172\u11ad"],["\ubbab","\u1106\u1172\u11ae"],["\ubbac","\u1106\u1172\u11af"],["\ubbad","\u1106\u1172\u11b0"],["\ubbae","\u1106\u1172\u11b1"],["\ubbaf","\u1106\u1172\u11b2"],["\ubbb0","\u1106\u1172\u11b3"],["\ubbb1","\u1106\u1172\u11b4"],["\ubbb2","\u1106\u1172\u11b5"],["\ubbb3","\u1106\u1172\u11b6"],["\ubbb4","\u1106\u1172\u11b7"],["\ubbb5","\u1106\u1172\u11b8"],["\ubbb6","\u1106\u1172\u11b9"],["\ubbb7","\u1106\u1172\u11ba"],["\ubbb8","\u1106\u1172\u11bb"],["\ubbb9","\u1106\u1172\u11bc"],["\ubbba","\u1106\u1172\u11bd"],["\ubbbb","\u1106\u1172\u11be"],["\ubbbc","\u1106\u1172\u11bf"],["\ubbbd","\u1106\u1172\u11c0"],["\ubbbe","\u1106\u1172\u11c1"],["\ubbbf","\u1106\u1172\u11c2"],["\ubbc0","\u1106\u1173"],["\ubbc1","\u1106\u1173\u11a8"],["\ubbc2","\u1106\u1173\u11a9"],["\ubbc3","\u1106\u1173\u11aa"],["\ubbc4","\u1106\u1173\u11ab"],["\ubbc5","\u1106\u1173\u11ac"],["\ubbc6","\u1106\u1173\u11ad"],["\ubbc7","\u1106\u1173\u11ae"],["\ubbc8","\u1106\u1173\u11af"],["\ubbc9","\u1106\u1173\u11b0"],["\ubbca","\u1106\u1173\u11b1"],["\ubbcb","\u1106\u1173\u11b2"],["\ubbcc","\u1106\u1173\u11b3"],["\ubbcd","\u1106\u1173\u11b4"],["\ubbce","\u1106\u1173\u11b5"],["\ubbcf","\u1106\u1173\u11b6"],["\ubbd0","\u1106\u1173\u11b7"],["\ubbd1","\u1106\u1173\u11b8"],["\ubbd2","\u1106\u1173\u11b9"],["\ubbd3","\u1106\u1173\u11ba"],["\ubbd4","\u1106\u1173\u11bb"],["\ubbd5","\u1106\u1173\u11bc"],["\ubbd6","\u1106\u1173\u11bd"],["\ubbd7","\u1106\u1173\u11be"],["\ubbd8","\u1106\u1173\u11bf"],["\ubbd9","\u1106\u1173\u11c0"],["\ubbda","\u1106\u1173\u11c1"],["\ubbdb","\u1106\u1173\u11c2"],["\ubbdc","\u1106\u1174"],["\ubbdd","\u1106\u1174\u11a8"],["\ubbde","\u1106\u1174\u11a9"],["\ubbdf","\u1106\u1174\u11aa"],["\ubbe0","\u1106\u1174\u11ab"],["\ubbe1","\u1106\u1174\u11ac"],["\ubbe2","\u1106\u1174\u11ad"],["\ubbe3","\u1106\u1174\u11ae"],["\ubbe4","\u1106\u1174\u11af"],["\ubbe5","\u1106\u1174\u11b0"],["\ubbe6","\u1106\u1174\u11b1"],["\ubbe7","\u1106\u1174\u11b2"],["\ubbe8","\u1106\u1174\u11b3"],["\ubbe9","\u1106\u1174\u11b4"],["\ubbea","\u1106\u1174\u11b5"],["\ubbeb","\u1106\u1174\u11b6"],["\ubbec","\u1106\u1174\u11b7"],["\ubbed","\u1106\u1174\u11b8"],["\ubbee","\u1106\u1174\u11b9"],["\ubbef","\u1106\u1174\u11ba"],["\ubbf0","\u1106\u1174\u11bb"],["\ubbf1","\u1106\u1174\u11bc"],["\ubbf2","\u1106\u1174\u11bd"],["\ubbf3","\u1106\u1174\u11be"],["\ubbf4","\u1106\u1174\u11bf"],["\ubbf5","\u1106\u1174\u11c0"],["\ubbf6","\u1106\u1174\u11c1"],["\ubbf7","\u1106\u1174\u11c2"],["\ubbf8","\u1106\u1175"],["\ubbf9","\u1106\u1175\u11a8"],["\ubbfa","\u1106\u1175\u11a9"],["\ubbfb","\u1106\u1175\u11aa"],["\ubbfc","\u1106\u1175\u11ab"],["\ubbfd","\u1106\u1175\u11ac"],["\ubbfe","\u1106\u1175\u11ad"],["\ubbff","\u1106\u1175\u11ae"],["\ubc00","\u1106\u1175\u11af"],["\ubc01","\u1106\u1175\u11b0"],["\ubc02","\u1106\u1175\u11b1"],["\ubc03","\u1106\u1175\u11b2"],["\ubc04","\u1106\u1175\u11b3"],["\ubc05","\u1106\u1175\u11b4"],["\ubc06","\u1106\u1175\u11b5"],["\ubc07","\u1106\u1175\u11b6"],["\ubc08","\u1106\u1175\u11b7"],["\ubc09","\u1106\u1175\u11b8"],["\ubc0a","\u1106\u1175\u11b9"],["\ubc0b","\u1106\u1175\u11ba"],["\ubc0c","\u1106\u1175\u11bb"],["\ubc0d","\u1106\u1175\u11bc"],["\ubc0e","\u1106\u1175\u11bd"],["\ubc0f","\u1106\u1175\u11be"],["\ubc10","\u1106\u1175\u11bf"],["\ubc11","\u1106\u1175\u11c0"],["\ubc12","\u1106\u1175\u11c1"],["\ubc13","\u1106\u1175\u11c2"],["\ubc14","\u1107\u1161"],["\ubc15","\u1107\u1161\u11a8"],["\ubc16","\u1107\u1161\u11a9"],["\ubc17","\u1107\u1161\u11aa"],["\ubc18","\u1107\u1161\u11ab"],["\ubc19","\u1107\u1161\u11ac"],["\ubc1a","\u1107\u1161\u11ad"],["\ubc1b","\u1107\u1161\u11ae"],["\ubc1c","\u1107\u1161\u11af"],["\ubc1d","\u1107\u1161\u11b0"],["\ubc1e","\u1107\u1161\u11b1"],["\ubc1f","\u1107\u1161\u11b2"],["\ubc20","\u1107\u1161\u11b3"],["\ubc21","\u1107\u1161\u11b4"],["\ubc22","\u1107\u1161\u11b5"],["\ubc23","\u1107\u1161\u11b6"],["\ubc24","\u1107\u1161\u11b7"],["\ubc25","\u1107\u1161\u11b8"],["\ubc26","\u1107\u1161\u11b9"],["\ubc27","\u1107\u1161\u11ba"],["\ubc28","\u1107\u1161\u11bb"],["\ubc29","\u1107\u1161\u11bc"],["\ubc2a","\u1107\u1161\u11bd"],["\ubc2b","\u1107\u1161\u11be"],["\ubc2c","\u1107\u1161\u11bf"],["\ubc2d","\u1107\u1161\u11c0"],["\ubc2e","\u1107\u1161\u11c1"],["\ubc2f","\u1107\u1161\u11c2"],["\ubc30","\u1107\u1162"],["\ubc31","\u1107\u1162\u11a8"],["\ubc32","\u1107\u1162\u11a9"],["\ubc33","\u1107\u1162\u11aa"],["\ubc34","\u1107\u1162\u11ab"],["\ubc35","\u1107\u1162\u11ac"],["\ubc36","\u1107\u1162\u11ad"],["\ubc37","\u1107\u1162\u11ae"],["\ubc38","\u1107\u1162\u11af"],["\ubc39","\u1107\u1162\u11b0"],["\ubc3a","\u1107\u1162\u11b1"],["\ubc3b","\u1107\u1162\u11b2"],["\ubc3c","\u1107\u1162\u11b3"],["\ubc3d","\u1107\u1162\u11b4"],["\ubc3e","\u1107\u1162\u11b5"],["\ubc3f","\u1107\u1162\u11b6"],["\ubc40","\u1107\u1162\u11b7"],["\ubc41","\u1107\u1162\u11b8"],["\ubc42","\u1107\u1162\u11b9"],["\ubc43","\u1107\u1162\u11ba"],["\ubc44","\u1107\u1162\u11bb"],["\ubc45","\u1107\u1162\u11bc"],["\ubc46","\u1107\u1162\u11bd"],["\ubc47","\u1107\u1162\u11be"],["\ubc48","\u1107\u1162\u11bf"],["\ubc49","\u1107\u1162\u11c0"],["\ubc4a","\u1107\u1162\u11c1"],["\ubc4b","\u1107\u1162\u11c2"],["\ubc4c","\u1107\u1163"],["\ubc4d","\u1107\u1163\u11a8"],["\ubc4e","\u1107\u1163\u11a9"],["\ubc4f","\u1107\u1163\u11aa"],["\ubc50","\u1107\u1163\u11ab"],["\ubc51","\u1107\u1163\u11ac"],["\ubc52","\u1107\u1163\u11ad"],["\ubc53","\u1107\u1163\u11ae"],["\ubc54","\u1107\u1163\u11af"],["\ubc55","\u1107\u1163\u11b0"],["\ubc56","\u1107\u1163\u11b1"],["\ubc57","\u1107\u1163\u11b2"],["\ubc58","\u1107\u1163\u11b3"],["\ubc59","\u1107\u1163\u11b4"],["\ubc5a","\u1107\u1163\u11b5"],["\ubc5b","\u1107\u1163\u11b6"],["\ubc5c","\u1107\u1163\u11b7"],["\ubc5d","\u1107\u1163\u11b8"],["\ubc5e","\u1107\u1163\u11b9"],["\ubc5f","\u1107\u1163\u11ba"],["\ubc60","\u1107\u1163\u11bb"],["\ubc61","\u1107\u1163\u11bc"],["\ubc62","\u1107\u1163\u11bd"],["\ubc63","\u1107\u1163\u11be"],["\ubc64","\u1107\u1163\u11bf"],["\ubc65","\u1107\u1163\u11c0"],["\ubc66","\u1107\u1163\u11c1"],["\ubc67","\u1107\u1163\u11c2"],["\ubc68","\u1107\u1164"],["\ubc69","\u1107\u1164\u11a8"],["\ubc6a","\u1107\u1164\u11a9"],["\ubc6b","\u1107\u1164\u11aa"],["\ubc6c","\u1107\u1164\u11ab"],["\ubc6d","\u1107\u1164\u11ac"],["\ubc6e","\u1107\u1164\u11ad"],["\ubc6f","\u1107\u1164\u11ae"],["\ubc70","\u1107\u1164\u11af"],["\ubc71","\u1107\u1164\u11b0"],["\ubc72","\u1107\u1164\u11b1"],["\ubc73","\u1107\u1164\u11b2"],["\ubc74","\u1107\u1164\u11b3"],["\ubc75","\u1107\u1164\u11b4"],["\ubc76","\u1107\u1164\u11b5"],["\ubc77","\u1107\u1164\u11b6"],["\ubc78","\u1107\u1164\u11b7"],["\ubc79","\u1107\u1164\u11b8"],["\ubc7a","\u1107\u1164\u11b9"],["\ubc7b","\u1107\u1164\u11ba"],["\ubc7c","\u1107\u1164\u11bb"],["\ubc7d","\u1107\u1164\u11bc"],["\ubc7e","\u1107\u1164\u11bd"],["\ubc7f","\u1107\u1164\u11be"],["\ubc80","\u1107\u1164\u11bf"],["\ubc81","\u1107\u1164\u11c0"],["\ubc82","\u1107\u1164\u11c1"],["\ubc83","\u1107\u1164\u11c2"],["\ubc84","\u1107\u1165"],["\ubc85","\u1107\u1165\u11a8"],["\ubc86","\u1107\u1165\u11a9"],["\ubc87","\u1107\u1165\u11aa"],["\ubc88","\u1107\u1165\u11ab"],["\ubc89","\u1107\u1165\u11ac"],["\ubc8a","\u1107\u1165\u11ad"],["\ubc8b","\u1107\u1165\u11ae"],["\ubc8c","\u1107\u1165\u11af"],["\ubc8d","\u1107\u1165\u11b0"],["\ubc8e","\u1107\u1165\u11b1"],["\ubc8f","\u1107\u1165\u11b2"],["\ubc90","\u1107\u1165\u11b3"],["\ubc91","\u1107\u1165\u11b4"],["\ubc92","\u1107\u1165\u11b5"],["\ubc93","\u1107\u1165\u11b6"],["\ubc94","\u1107\u1165\u11b7"],["\ubc95","\u1107\u1165\u11b8"],["\ubc96","\u1107\u1165\u11b9"],["\ubc97","\u1107\u1165\u11ba"],["\ubc98","\u1107\u1165\u11bb"],["\ubc99","\u1107\u1165\u11bc"],["\ubc9a","\u1107\u1165\u11bd"],["\ubc9b","\u1107\u1165\u11be"],["\ubc9c","\u1107\u1165\u11bf"],["\ubc9d","\u1107\u1165\u11c0"],["\ubc9e","\u1107\u1165\u11c1"],["\ubc9f","\u1107\u1165\u11c2"],["\ubca0","\u1107\u1166"],["\ubca1","\u1107\u1166\u11a8"],["\ubca2","\u1107\u1166\u11a9"],["\ubca3","\u1107\u1166\u11aa"],["\ubca4","\u1107\u1166\u11ab"],["\ubca5","\u1107\u1166\u11ac"],["\ubca6","\u1107\u1166\u11ad"],["\ubca7","\u1107\u1166\u11ae"],["\ubca8","\u1107\u1166\u11af"],["\ubca9","\u1107\u1166\u11b0"],["\ubcaa","\u1107\u1166\u11b1"],["\ubcab","\u1107\u1166\u11b2"],["\ubcac","\u1107\u1166\u11b3"],["\ubcad","\u1107\u1166\u11b4"],["\ubcae","\u1107\u1166\u11b5"],["\ubcaf","\u1107\u1166\u11b6"],["\ubcb0","\u1107\u1166\u11b7"],["\ubcb1","\u1107\u1166\u11b8"],["\ubcb2","\u1107\u1166\u11b9"],["\ubcb3","\u1107\u1166\u11ba"],["\ubcb4","\u1107\u1166\u11bb"],["\ubcb5","\u1107\u1166\u11bc"],["\ubcb6","\u1107\u1166\u11bd"],["\ubcb7","\u1107\u1166\u11be"],["\ubcb8","\u1107\u1166\u11bf"],["\ubcb9","\u1107\u1166\u11c0"],["\ubcba","\u1107\u1166\u11c1"],["\ubcbb","\u1107\u1166\u11c2"],["\ubcbc","\u1107\u1167"],["\ubcbd","\u1107\u1167\u11a8"],["\ubcbe","\u1107\u1167\u11a9"],["\ubcbf","\u1107\u1167\u11aa"],["\ubcc0","\u1107\u1167\u11ab"],["\ubcc1","\u1107\u1167\u11ac"],["\ubcc2","\u1107\u1167\u11ad"],["\ubcc3","\u1107\u1167\u11ae"],["\ubcc4","\u1107\u1167\u11af"],["\ubcc5","\u1107\u1167\u11b0"],["\ubcc6","\u1107\u1167\u11b1"],["\ubcc7","\u1107\u1167\u11b2"],["\ubcc8","\u1107\u1167\u11b3"],["\ubcc9","\u1107\u1167\u11b4"],["\ubcca","\u1107\u1167\u11b5"],["\ubccb","\u1107\u1167\u11b6"],["\ubccc","\u1107\u1167\u11b7"],["\ubccd","\u1107\u1167\u11b8"],["\ubcce","\u1107\u1167\u11b9"],["\ubccf","\u1107\u1167\u11ba"],["\ubcd0","\u1107\u1167\u11bb"],["\ubcd1","\u1107\u1167\u11bc"],["\ubcd2","\u1107\u1167\u11bd"],["\ubcd3","\u1107\u1167\u11be"],["\ubcd4","\u1107\u1167\u11bf"],["\ubcd5","\u1107\u1167\u11c0"],["\ubcd6","\u1107\u1167\u11c1"],["\ubcd7","\u1107\u1167\u11c2"],["\ubcd8","\u1107\u1168"],["\ubcd9","\u1107\u1168\u11a8"],["\ubcda","\u1107\u1168\u11a9"],["\ubcdb","\u1107\u1168\u11aa"],["\ubcdc","\u1107\u1168\u11ab"],["\ubcdd","\u1107\u1168\u11ac"],["\ubcde","\u1107\u1168\u11ad"],["\ubcdf","\u1107\u1168\u11ae"],["\ubce0","\u1107\u1168\u11af"],["\ubce1","\u1107\u1168\u11b0"],["\ubce2","\u1107\u1168\u11b1"],["\ubce3","\u1107\u1168\u11b2"],["\ubce4","\u1107\u1168\u11b3"],["\ubce5","\u1107\u1168\u11b4"],["\ubce6","\u1107\u1168\u11b5"],["\ubce7","\u1107\u1168\u11b6"],["\ubce8","\u1107\u1168\u11b7"],["\ubce9","\u1107\u1168\u11b8"],["\ubcea","\u1107\u1168\u11b9"],["\ubceb","\u1107\u1168\u11ba"],["\ubcec","\u1107\u1168\u11bb"],["\ubced","\u1107\u1168\u11bc"],["\ubcee","\u1107\u1168\u11bd"],["\ubcef","\u1107\u1168\u11be"],["\ubcf0","\u1107\u1168\u11bf"],["\ubcf1","\u1107\u1168\u11c0"],["\ubcf2","\u1107\u1168\u11c1"],["\ubcf3","\u1107\u1168\u11c2"],["\ubcf4","\u1107\u1169"],["\ubcf5","\u1107\u1169\u11a8"],["\ubcf6","\u1107\u1169\u11a9"],["\ubcf7","\u1107\u1169\u11aa"],["\ubcf8","\u1107\u1169\u11ab"],["\ubcf9","\u1107\u1169\u11ac"],["\ubcfa","\u1107\u1169\u11ad"],["\ubcfb","\u1107\u1169\u11ae"],["\ubcfc","\u1107\u1169\u11af"],["\ubcfd","\u1107\u1169\u11b0"],["\ubcfe","\u1107\u1169\u11b1"],["\ubcff","\u1107\u1169\u11b2"],["\ubd00","\u1107\u1169\u11b3"],["\ubd01","\u1107\u1169\u11b4"],["\ubd02","\u1107\u1169\u11b5"],["\ubd03","\u1107\u1169\u11b6"],["\ubd04","\u1107\u1169\u11b7"],["\ubd05","\u1107\u1169\u11b8"],["\ubd06","\u1107\u1169\u11b9"],["\ubd07","\u1107\u1169\u11ba"],["\ubd08","\u1107\u1169\u11bb"],["\ubd09","\u1107\u1169\u11bc"],["\ubd0a","\u1107\u1169\u11bd"],["\ubd0b","\u1107\u1169\u11be"],["\ubd0c","\u1107\u1169\u11bf"],["\ubd0d","\u1107\u1169\u11c0"],["\ubd0e","\u1107\u1169\u11c1"],["\ubd0f","\u1107\u1169\u11c2"],["\ubd10","\u1107\u116a"],["\ubd11","\u1107\u116a\u11a8"],["\ubd12","\u1107\u116a\u11a9"],["\ubd13","\u1107\u116a\u11aa"],["\ubd14","\u1107\u116a\u11ab"],["\ubd15","\u1107\u116a\u11ac"],["\ubd16","\u1107\u116a\u11ad"],["\ubd17","\u1107\u116a\u11ae"],["\ubd18","\u1107\u116a\u11af"],["\ubd19","\u1107\u116a\u11b0"],["\ubd1a","\u1107\u116a\u11b1"],["\ubd1b","\u1107\u116a\u11b2"],["\ubd1c","\u1107\u116a\u11b3"],["\ubd1d","\u1107\u116a\u11b4"],["\ubd1e","\u1107\u116a\u11b5"],["\ubd1f","\u1107\u116a\u11b6"],["\ubd20","\u1107\u116a\u11b7"],["\ubd21","\u1107\u116a\u11b8"],["\ubd22","\u1107\u116a\u11b9"],["\ubd23","\u1107\u116a\u11ba"],["\ubd24","\u1107\u116a\u11bb"],["\ubd25","\u1107\u116a\u11bc"],["\ubd26","\u1107\u116a\u11bd"],["\ubd27","\u1107\u116a\u11be"],["\ubd28","\u1107\u116a\u11bf"],["\ubd29","\u1107\u116a\u11c0"],["\ubd2a","\u1107\u116a\u11c1"],["\ubd2b","\u1107\u116a\u11c2"],["\ubd2c","\u1107\u116b"],["\ubd2d","\u1107\u116b\u11a8"],["\ubd2e","\u1107\u116b\u11a9"],["\ubd2f","\u1107\u116b\u11aa"],["\ubd30","\u1107\u116b\u11ab"],["\ubd31","\u1107\u116b\u11ac"],["\ubd32","\u1107\u116b\u11ad"],["\ubd33","\u1107\u116b\u11ae"],["\ubd34","\u1107\u116b\u11af"],["\ubd35","\u1107\u116b\u11b0"],["\ubd36","\u1107\u116b\u11b1"],["\ubd37","\u1107\u116b\u11b2"],["\ubd38","\u1107\u116b\u11b3"],["\ubd39","\u1107\u116b\u11b4"],["\ubd3a","\u1107\u116b\u11b5"],["\ubd3b","\u1107\u116b\u11b6"],["\ubd3c","\u1107\u116b\u11b7"],["\ubd3d","\u1107\u116b\u11b8"],["\ubd3e","\u1107\u116b\u11b9"],["\ubd3f","\u1107\u116b\u11ba"],["\ubd40","\u1107\u116b\u11bb"],["\ubd41","\u1107\u116b\u11bc"],["\ubd42","\u1107\u116b\u11bd"],["\ubd43","\u1107\u116b\u11be"],["\ubd44","\u1107\u116b\u11bf"],["\ubd45","\u1107\u116b\u11c0"],["\ubd46","\u1107\u116b\u11c1"],["\ubd47","\u1107\u116b\u11c2"],["\ubd48","\u1107\u116c"],["\ubd49","\u1107\u116c\u11a8"],["\ubd4a","\u1107\u116c\u11a9"],["\ubd4b","\u1107\u116c\u11aa"],["\ubd4c","\u1107\u116c\u11ab"],["\ubd4d","\u1107\u116c\u11ac"],["\ubd4e","\u1107\u116c\u11ad"],["\ubd4f","\u1107\u116c\u11ae"],["\ubd50","\u1107\u116c\u11af"],["\ubd51","\u1107\u116c\u11b0"],["\ubd52","\u1107\u116c\u11b1"],["\ubd53","\u1107\u116c\u11b2"],["\ubd54","\u1107\u116c\u11b3"],["\ubd55","\u1107\u116c\u11b4"],["\ubd56","\u1107\u116c\u11b5"],["\ubd57","\u1107\u116c\u11b6"],["\ubd58","\u1107\u116c\u11b7"],["\ubd59","\u1107\u116c\u11b8"],["\ubd5a","\u1107\u116c\u11b9"],["\ubd5b","\u1107\u116c\u11ba"],["\ubd5c","\u1107\u116c\u11bb"],["\ubd5d","\u1107\u116c\u11bc"],["\ubd5e","\u1107\u116c\u11bd"],["\ubd5f","\u1107\u116c\u11be"],["\ubd60","\u1107\u116c\u11bf"],["\ubd61","\u1107\u116c\u11c0"],["\ubd62","\u1107\u116c\u11c1"],["\ubd63","\u1107\u116c\u11c2"],["\ubd64","\u1107\u116d"],["\ubd65","\u1107\u116d\u11a8"],["\ubd66","\u1107\u116d\u11a9"],["\ubd67","\u1107\u116d\u11aa"],["\ubd68","\u1107\u116d\u11ab"],["\ubd69","\u1107\u116d\u11ac"],["\ubd6a","\u1107\u116d\u11ad"],["\ubd6b","\u1107\u116d\u11ae"],["\ubd6c","\u1107\u116d\u11af"],["\ubd6d","\u1107\u116d\u11b0"],["\ubd6e","\u1107\u116d\u11b1"],["\ubd6f","\u1107\u116d\u11b2"],["\ubd70","\u1107\u116d\u11b3"],["\ubd71","\u1107\u116d\u11b4"],["\ubd72","\u1107\u116d\u11b5"],["\ubd73","\u1107\u116d\u11b6"],["\ubd74","\u1107\u116d\u11b7"],["\ubd75","\u1107\u116d\u11b8"],["\ubd76","\u1107\u116d\u11b9"],["\ubd77","\u1107\u116d\u11ba"],["\ubd78","\u1107\u116d\u11bb"],["\ubd79","\u1107\u116d\u11bc"],["\ubd7a","\u1107\u116d\u11bd"],["\ubd7b","\u1107\u116d\u11be"],["\ubd7c","\u1107\u116d\u11bf"],["\ubd7d","\u1107\u116d\u11c0"],["\ubd7e","\u1107\u116d\u11c1"],["\ubd7f","\u1107\u116d\u11c2"],["\ubd80","\u1107\u116e"],["\ubd81","\u1107\u116e\u11a8"],["\ubd82","\u1107\u116e\u11a9"],["\ubd83","\u1107\u116e\u11aa"],["\ubd84","\u1107\u116e\u11ab"],["\ubd85","\u1107\u116e\u11ac"],["\ubd86","\u1107\u116e\u11ad"],["\ubd87","\u1107\u116e\u11ae"],["\ubd88","\u1107\u116e\u11af"],["\ubd89","\u1107\u116e\u11b0"],["\ubd8a","\u1107\u116e\u11b1"],["\ubd8b","\u1107\u116e\u11b2"],["\ubd8c","\u1107\u116e\u11b3"],["\ubd8d","\u1107\u116e\u11b4"],["\ubd8e","\u1107\u116e\u11b5"],["\ubd8f","\u1107\u116e\u11b6"],["\ubd90","\u1107\u116e\u11b7"],["\ubd91","\u1107\u116e\u11b8"],["\ubd92","\u1107\u116e\u11b9"],["\ubd93","\u1107\u116e\u11ba"],["\ubd94","\u1107\u116e\u11bb"],["\ubd95","\u1107\u116e\u11bc"],["\ubd96","\u1107\u116e\u11bd"],["\ubd97","\u1107\u116e\u11be"],["\ubd98","\u1107\u116e\u11bf"],["\ubd99","\u1107\u116e\u11c0"],["\ubd9a","\u1107\u116e\u11c1"],["\ubd9b","\u1107\u116e\u11c2"],["\ubd9c","\u1107\u116f"],["\ubd9d","\u1107\u116f\u11a8"],["\ubd9e","\u1107\u116f\u11a9"],["\ubd9f","\u1107\u116f\u11aa"],["\ubda0","\u1107\u116f\u11ab"],["\ubda1","\u1107\u116f\u11ac"],["\ubda2","\u1107\u116f\u11ad"],["\ubda3","\u1107\u116f\u11ae"],["\ubda4","\u1107\u116f\u11af"],["\ubda5","\u1107\u116f\u11b0"],["\ubda6","\u1107\u116f\u11b1"],["\ubda7","\u1107\u116f\u11b2"],["\ubda8","\u1107\u116f\u11b3"],["\ubda9","\u1107\u116f\u11b4"],["\ubdaa","\u1107\u116f\u11b5"],["\ubdab","\u1107\u116f\u11b6"],["\ubdac","\u1107\u116f\u11b7"],["\ubdad","\u1107\u116f\u11b8"],["\ubdae","\u1107\u116f\u11b9"],["\ubdaf","\u1107\u116f\u11ba"],["\ubdb0","\u1107\u116f\u11bb"],["\ubdb1","\u1107\u116f\u11bc"],["\ubdb2","\u1107\u116f\u11bd"],["\ubdb3","\u1107\u116f\u11be"],["\ubdb4","\u1107\u116f\u11bf"],["\ubdb5","\u1107\u116f\u11c0"],["\ubdb6","\u1107\u116f\u11c1"],["\ubdb7","\u1107\u116f\u11c2"],["\ubdb8","\u1107\u1170"],["\ubdb9","\u1107\u1170\u11a8"],["\ubdba","\u1107\u1170\u11a9"],["\ubdbb","\u1107\u1170\u11aa"],["\ubdbc","\u1107\u1170\u11ab"],["\ubdbd","\u1107\u1170\u11ac"],["\ubdbe","\u1107\u1170\u11ad"],["\ubdbf","\u1107\u1170\u11ae"],["\ubdc0","\u1107\u1170\u11af"],["\ubdc1","\u1107\u1170\u11b0"],["\ubdc2","\u1107\u1170\u11b1"],["\ubdc3","\u1107\u1170\u11b2"],["\ubdc4","\u1107\u1170\u11b3"],["\ubdc5","\u1107\u1170\u11b4"],["\ubdc6","\u1107\u1170\u11b5"],["\ubdc7","\u1107\u1170\u11b6"],["\ubdc8","\u1107\u1170\u11b7"],["\ubdc9","\u1107\u1170\u11b8"],["\ubdca","\u1107\u1170\u11b9"],["\ubdcb","\u1107\u1170\u11ba"],["\ubdcc","\u1107\u1170\u11bb"],["\ubdcd","\u1107\u1170\u11bc"],["\ubdce","\u1107\u1170\u11bd"],["\ubdcf","\u1107\u1170\u11be"],["\ubdd0","\u1107\u1170\u11bf"],["\ubdd1","\u1107\u1170\u11c0"],["\ubdd2","\u1107\u1170\u11c1"],["\ubdd3","\u1107\u1170\u11c2"],["\ubdd4","\u1107\u1171"],["\ubdd5","\u1107\u1171\u11a8"],["\ubdd6","\u1107\u1171\u11a9"],["\ubdd7","\u1107\u1171\u11aa"],["\ubdd8","\u1107\u1171\u11ab"],["\ubdd9","\u1107\u1171\u11ac"],["\ubdda","\u1107\u1171\u11ad"],["\ubddb","\u1107\u1171\u11ae"],["\ubddc","\u1107\u1171\u11af"],["\ubddd","\u1107\u1171\u11b0"],["\ubdde","\u1107\u1171\u11b1"],["\ubddf","\u1107\u1171\u11b2"],["\ubde0","\u1107\u1171\u11b3"],["\ubde1","\u1107\u1171\u11b4"],["\ubde2","\u1107\u1171\u11b5"],["\ubde3","\u1107\u1171\u11b6"],["\ubde4","\u1107\u1171\u11b7"],["\ubde5","\u1107\u1171\u11b8"],["\ubde6","\u1107\u1171\u11b9"],["\ubde7","\u1107\u1171\u11ba"],["\ubde8","\u1107\u1171\u11bb"],["\ubde9","\u1107\u1171\u11bc"],["\ubdea","\u1107\u1171\u11bd"],["\ubdeb","\u1107\u1171\u11be"],["\ubdec","\u1107\u1171\u11bf"],["\ubded","\u1107\u1171\u11c0"],["\ubdee","\u1107\u1171\u11c1"],["\ubdef","\u1107\u1171\u11c2"],["\ubdf0","\u1107\u1172"],["\ubdf1","\u1107\u1172\u11a8"],["\ubdf2","\u1107\u1172\u11a9"],["\ubdf3","\u1107\u1172\u11aa"],["\ubdf4","\u1107\u1172\u11ab"],["\ubdf5","\u1107\u1172\u11ac"],["\ubdf6","\u1107\u1172\u11ad"],["\ubdf7","\u1107\u1172\u11ae"],["\ubdf8","\u1107\u1172\u11af"],["\ubdf9","\u1107\u1172\u11b0"],["\ubdfa","\u1107\u1172\u11b1"],["\ubdfb","\u1107\u1172\u11b2"],["\ubdfc","\u1107\u1172\u11b3"],["\ubdfd","\u1107\u1172\u11b4"],["\ubdfe","\u1107\u1172\u11b5"],["\ubdff","\u1107\u1172\u11b6"],["\ube00","\u1107\u1172\u11b7"],["\ube01","\u1107\u1172\u11b8"],["\ube02","\u1107\u1172\u11b9"],["\ube03","\u1107\u1172\u11ba"],["\ube04","\u1107\u1172\u11bb"],["\ube05","\u1107\u1172\u11bc"],["\ube06","\u1107\u1172\u11bd"],["\ube07","\u1107\u1172\u11be"],["\ube08","\u1107\u1172\u11bf"],["\ube09","\u1107\u1172\u11c0"],["\ube0a","\u1107\u1172\u11c1"],["\ube0b","\u1107\u1172\u11c2"],["\ube0c","\u1107\u1173"],["\ube0d","\u1107\u1173\u11a8"],["\ube0e","\u1107\u1173\u11a9"],["\ube0f","\u1107\u1173\u11aa"],["\ube10","\u1107\u1173\u11ab"],["\ube11","\u1107\u1173\u11ac"],["\ube12","\u1107\u1173\u11ad"],["\ube13","\u1107\u1173\u11ae"],["\ube14","\u1107\u1173\u11af"],["\ube15","\u1107\u1173\u11b0"],["\ube16","\u1107\u1173\u11b1"],["\ube17","\u1107\u1173\u11b2"],["\ube18","\u1107\u1173\u11b3"],["\ube19","\u1107\u1173\u11b4"],["\ube1a","\u1107\u1173\u11b5"],["\ube1b","\u1107\u1173\u11b6"],["\ube1c","\u1107\u1173\u11b7"],["\ube1d","\u1107\u1173\u11b8"],["\ube1e","\u1107\u1173\u11b9"],["\ube1f","\u1107\u1173\u11ba"],["\ube20","\u1107\u1173\u11bb"],["\ube21","\u1107\u1173\u11bc"],["\ube22","\u1107\u1173\u11bd"],["\ube23","\u1107\u1173\u11be"],["\ube24","\u1107\u1173\u11bf"],["\ube25","\u1107\u1173\u11c0"],["\ube26","\u1107\u1173\u11c1"],["\ube27","\u1107\u1173\u11c2"],["\ube28","\u1107\u1174"],["\ube29","\u1107\u1174\u11a8"],["\ube2a","\u1107\u1174\u11a9"],["\ube2b","\u1107\u1174\u11aa"],["\ube2c","\u1107\u1174\u11ab"],["\ube2d","\u1107\u1174\u11ac"],["\ube2e","\u1107\u1174\u11ad"],["\ube2f","\u1107\u1174\u11ae"],["\ube30","\u1107\u1174\u11af"],["\ube31","\u1107\u1174\u11b0"],["\ube32","\u1107\u1174\u11b1"],["\ube33","\u1107\u1174\u11b2"],["\ube34","\u1107\u1174\u11b3"],["\ube35","\u1107\u1174\u11b4"],["\ube36","\u1107\u1174\u11b5"],["\ube37","\u1107\u1174\u11b6"],["\ube38","\u1107\u1174\u11b7"],["\ube39","\u1107\u1174\u11b8"],["\ube3a","\u1107\u1174\u11b9"],["\ube3b","\u1107\u1174\u11ba"],["\ube3c","\u1107\u1174\u11bb"],["\ube3d","\u1107\u1174\u11bc"],["\ube3e","\u1107\u1174\u11bd"],["\ube3f","\u1107\u1174\u11be"],["\ube40","\u1107\u1174\u11bf"],["\ube41","\u1107\u1174\u11c0"],["\ube42","\u1107\u1174\u11c1"],["\ube43","\u1107\u1174\u11c2"],["\ube44","\u1107\u1175"],["\ube45","\u1107\u1175\u11a8"],["\ube46","\u1107\u1175\u11a9"],["\ube47","\u1107\u1175\u11aa"],["\ube48","\u1107\u1175\u11ab"],["\ube49","\u1107\u1175\u11ac"],["\ube4a","\u1107\u1175\u11ad"],["\ube4b","\u1107\u1175\u11ae"],["\ube4c","\u1107\u1175\u11af"],["\ube4d","\u1107\u1175\u11b0"],["\ube4e","\u1107\u1175\u11b1"],["\ube4f","\u1107\u1175\u11b2"],["\ube50","\u1107\u1175\u11b3"],["\ube51","\u1107\u1175\u11b4"],["\ube52","\u1107\u1175\u11b5"],["\ube53","\u1107\u1175\u11b6"],["\ube54","\u1107\u1175\u11b7"],["\ube55","\u1107\u1175\u11b8"],["\ube56","\u1107\u1175\u11b9"],["\ube57","\u1107\u1175\u11ba"],["\ube58","\u1107\u1175\u11bb"],["\ube59","\u1107\u1175\u11bc"],["\ube5a","\u1107\u1175\u11bd"],["\ube5b","\u1107\u1175\u11be"],["\ube5c","\u1107\u1175\u11bf"],["\ube5d","\u1107\u1175\u11c0"],["\ube5e","\u1107\u1175\u11c1"],["\ube5f","\u1107\u1175\u11c2"],["\ube60","\u1108\u1161"],["\ube61","\u1108\u1161\u11a8"],["\ube62","\u1108\u1161\u11a9"],["\ube63","\u1108\u1161\u11aa"],["\ube64","\u1108\u1161\u11ab"],["\ube65","\u1108\u1161\u11ac"],["\ube66","\u1108\u1161\u11ad"],["\ube67","\u1108\u1161\u11ae"],["\ube68","\u1108\u1161\u11af"],["\ube69","\u1108\u1161\u11b0"],["\ube6a","\u1108\u1161\u11b1"],["\ube6b","\u1108\u1161\u11b2"],["\ube6c","\u1108\u1161\u11b3"],["\ube6d","\u1108\u1161\u11b4"],["\ube6e","\u1108\u1161\u11b5"],["\ube6f","\u1108\u1161\u11b6"],["\ube70","\u1108\u1161\u11b7"],["\ube71","\u1108\u1161\u11b8"],["\ube72","\u1108\u1161\u11b9"],["\ube73","\u1108\u1161\u11ba"],["\ube74","\u1108\u1161\u11bb"],["\ube75","\u1108\u1161\u11bc"],["\ube76","\u1108\u1161\u11bd"],["\ube77","\u1108\u1161\u11be"],["\ube78","\u1108\u1161\u11bf"],["\ube79","\u1108\u1161\u11c0"],["\ube7a","\u1108\u1161\u11c1"],["\ube7b","\u1108\u1161\u11c2"],["\ube7c","\u1108\u1162"],["\ube7d","\u1108\u1162\u11a8"],["\ube7e","\u1108\u1162\u11a9"],["\ube7f","\u1108\u1162\u11aa"],["\ube80","\u1108\u1162\u11ab"],["\ube81","\u1108\u1162\u11ac"],["\ube82","\u1108\u1162\u11ad"],["\ube83","\u1108\u1162\u11ae"],["\ube84","\u1108\u1162\u11af"],["\ube85","\u1108\u1162\u11b0"],["\ube86","\u1108\u1162\u11b1"],["\ube87","\u1108\u1162\u11b2"],["\ube88","\u1108\u1162\u11b3"],["\ube89","\u1108\u1162\u11b4"],["\ube8a","\u1108\u1162\u11b5"],["\ube8b","\u1108\u1162\u11b6"],["\ube8c","\u1108\u1162\u11b7"],["\ube8d","\u1108\u1162\u11b8"],["\ube8e","\u1108\u1162\u11b9"],["\ube8f","\u1108\u1162\u11ba"],["\ube90","\u1108\u1162\u11bb"],["\ube91","\u1108\u1162\u11bc"],["\ube92","\u1108\u1162\u11bd"],["\ube93","\u1108\u1162\u11be"],["\ube94","\u1108\u1162\u11bf"],["\ube95","\u1108\u1162\u11c0"],["\ube96","\u1108\u1162\u11c1"],["\ube97","\u1108\u1162\u11c2"],["\ube98","\u1108\u1163"],["\ube99","\u1108\u1163\u11a8"],["\ube9a","\u1108\u1163\u11a9"],["\ube9b","\u1108\u1163\u11aa"],["\ube9c","\u1108\u1163\u11ab"],["\ube9d","\u1108\u1163\u11ac"],["\ube9e","\u1108\u1163\u11ad"],["\ube9f","\u1108\u1163\u11ae"],["\ubea0","\u1108\u1163\u11af"],["\ubea1","\u1108\u1163\u11b0"],["\ubea2","\u1108\u1163\u11b1"],["\ubea3","\u1108\u1163\u11b2"],["\ubea4","\u1108\u1163\u11b3"],["\ubea5","\u1108\u1163\u11b4"],["\ubea6","\u1108\u1163\u11b5"],["\ubea7","\u1108\u1163\u11b6"],["\ubea8","\u1108\u1163\u11b7"],["\ubea9","\u1108\u1163\u11b8"],["\ubeaa","\u1108\u1163\u11b9"],["\ubeab","\u1108\u1163\u11ba"],["\ubeac","\u1108\u1163\u11bb"],["\ubead","\u1108\u1163\u11bc"],["\ubeae","\u1108\u1163\u11bd"],["\ubeaf","\u1108\u1163\u11be"],["\ubeb0","\u1108\u1163\u11bf"],["\ubeb1","\u1108\u1163\u11c0"],["\ubeb2","\u1108\u1163\u11c1"],["\ubeb3","\u1108\u1163\u11c2"],["\ubeb4","\u1108\u1164"],["\ubeb5","\u1108\u1164\u11a8"],["\ubeb6","\u1108\u1164\u11a9"],["\ubeb7","\u1108\u1164\u11aa"],["\ubeb8","\u1108\u1164\u11ab"],["\ubeb9","\u1108\u1164\u11ac"],["\ubeba","\u1108\u1164\u11ad"],["\ubebb","\u1108\u1164\u11ae"],["\ubebc","\u1108\u1164\u11af"],["\ubebd","\u1108\u1164\u11b0"],["\ubebe","\u1108\u1164\u11b1"],["\ubebf","\u1108\u1164\u11b2"],["\ubec0","\u1108\u1164\u11b3"],["\ubec1","\u1108\u1164\u11b4"],["\ubec2","\u1108\u1164\u11b5"],["\ubec3","\u1108\u1164\u11b6"],["\ubec4","\u1108\u1164\u11b7"],["\ubec5","\u1108\u1164\u11b8"],["\ubec6","\u1108\u1164\u11b9"],["\ubec7","\u1108\u1164\u11ba"],["\ubec8","\u1108\u1164\u11bb"],["\ubec9","\u1108\u1164\u11bc"],["\ubeca","\u1108\u1164\u11bd"],["\ubecb","\u1108\u1164\u11be"],["\ubecc","\u1108\u1164\u11bf"],["\ubecd","\u1108\u1164\u11c0"],["\ubece","\u1108\u1164\u11c1"],["\ubecf","\u1108\u1164\u11c2"],["\ubed0","\u1108\u1165"],["\ubed1","\u1108\u1165\u11a8"],["\ubed2","\u1108\u1165\u11a9"],["\ubed3","\u1108\u1165\u11aa"],["\ubed4","\u1108\u1165\u11ab"],["\ubed5","\u1108\u1165\u11ac"],["\ubed6","\u1108\u1165\u11ad"],["\ubed7","\u1108\u1165\u11ae"],["\ubed8","\u1108\u1165\u11af"],["\ubed9","\u1108\u1165\u11b0"],["\ubeda","\u1108\u1165\u11b1"],["\ubedb","\u1108\u1165\u11b2"],["\ubedc","\u1108\u1165\u11b3"],["\ubedd","\u1108\u1165\u11b4"],["\ubede","\u1108\u1165\u11b5"],["\ubedf","\u1108\u1165\u11b6"],["\ubee0","\u1108\u1165\u11b7"],["\ubee1","\u1108\u1165\u11b8"],["\ubee2","\u1108\u1165\u11b9"],["\ubee3","\u1108\u1165\u11ba"],["\ubee4","\u1108\u1165\u11bb"],["\ubee5","\u1108\u1165\u11bc"],["\ubee6","\u1108\u1165\u11bd"],["\ubee7","\u1108\u1165\u11be"],["\ubee8","\u1108\u1165\u11bf"],["\ubee9","\u1108\u1165\u11c0"],["\ubeea","\u1108\u1165\u11c1"],["\ubeeb","\u1108\u1165\u11c2"],["\ubeec","\u1108\u1166"],["\ubeed","\u1108\u1166\u11a8"],["\ubeee","\u1108\u1166\u11a9"],["\ubeef","\u1108\u1166\u11aa"],["\ubef0","\u1108\u1166\u11ab"],["\ubef1","\u1108\u1166\u11ac"],["\ubef2","\u1108\u1166\u11ad"],["\ubef3","\u1108\u1166\u11ae"],["\ubef4","\u1108\u1166\u11af"],["\ubef5","\u1108\u1166\u11b0"],["\ubef6","\u1108\u1166\u11b1"],["\ubef7","\u1108\u1166\u11b2"],["\ubef8","\u1108\u1166\u11b3"],["\ubef9","\u1108\u1166\u11b4"],["\ubefa","\u1108\u1166\u11b5"],["\ubefb","\u1108\u1166\u11b6"],["\ubefc","\u1108\u1166\u11b7"],["\ubefd","\u1108\u1166\u11b8"],["\ubefe","\u1108\u1166\u11b9"],["\ubeff","\u1108\u1166\u11ba"],["\ubf00","\u1108\u1166\u11bb"],["\ubf01","\u1108\u1166\u11bc"],["\ubf02","\u1108\u1166\u11bd"],["\ubf03","\u1108\u1166\u11be"],["\ubf04","\u1108\u1166\u11bf"],["\ubf05","\u1108\u1166\u11c0"],["\ubf06","\u1108\u1166\u11c1"],["\ubf07","\u1108\u1166\u11c2"],["\ubf08","\u1108\u1167"],["\ubf09","\u1108\u1167\u11a8"],["\ubf0a","\u1108\u1167\u11a9"],["\ubf0b","\u1108\u1167\u11aa"],["\ubf0c","\u1108\u1167\u11ab"],["\ubf0d","\u1108\u1167\u11ac"],["\ubf0e","\u1108\u1167\u11ad"],["\ubf0f","\u1108\u1167\u11ae"],["\ubf10","\u1108\u1167\u11af"],["\ubf11","\u1108\u1167\u11b0"],["\ubf12","\u1108\u1167\u11b1"],["\ubf13","\u1108\u1167\u11b2"],["\ubf14","\u1108\u1167\u11b3"],["\ubf15","\u1108\u1167\u11b4"],["\ubf16","\u1108\u1167\u11b5"],["\ubf17","\u1108\u1167\u11b6"],["\ubf18","\u1108\u1167\u11b7"],["\ubf19","\u1108\u1167\u11b8"],["\ubf1a","\u1108\u1167\u11b9"],["\ubf1b","\u1108\u1167\u11ba"],["\ubf1c","\u1108\u1167\u11bb"],["\ubf1d","\u1108\u1167\u11bc"],["\ubf1e","\u1108\u1167\u11bd"],["\ubf1f","\u1108\u1167\u11be"],["\ubf20","\u1108\u1167\u11bf"],["\ubf21","\u1108\u1167\u11c0"],["\ubf22","\u1108\u1167\u11c1"],["\ubf23","\u1108\u1167\u11c2"],["\ubf24","\u1108\u1168"],["\ubf25","\u1108\u1168\u11a8"],["\ubf26","\u1108\u1168\u11a9"],["\ubf27","\u1108\u1168\u11aa"],["\ubf28","\u1108\u1168\u11ab"],["\ubf29","\u1108\u1168\u11ac"],["\ubf2a","\u1108\u1168\u11ad"],["\ubf2b","\u1108\u1168\u11ae"],["\ubf2c","\u1108\u1168\u11af"],["\ubf2d","\u1108\u1168\u11b0"],["\ubf2e","\u1108\u1168\u11b1"],["\ubf2f","\u1108\u1168\u11b2"],["\ubf30","\u1108\u1168\u11b3"],["\ubf31","\u1108\u1168\u11b4"],["\ubf32","\u1108\u1168\u11b5"],["\ubf33","\u1108\u1168\u11b6"],["\ubf34","\u1108\u1168\u11b7"],["\ubf35","\u1108\u1168\u11b8"],["\ubf36","\u1108\u1168\u11b9"],["\ubf37","\u1108\u1168\u11ba"],["\ubf38","\u1108\u1168\u11bb"],["\ubf39","\u1108\u1168\u11bc"],["\ubf3a","\u1108\u1168\u11bd"],["\ubf3b","\u1108\u1168\u11be"],["\ubf3c","\u1108\u1168\u11bf"],["\ubf3d","\u1108\u1168\u11c0"],["\ubf3e","\u1108\u1168\u11c1"],["\ubf3f","\u1108\u1168\u11c2"],["\ubf40","\u1108\u1169"],["\ubf41","\u1108\u1169\u11a8"],["\ubf42","\u1108\u1169\u11a9"],["\ubf43","\u1108\u1169\u11aa"],["\ubf44","\u1108\u1169\u11ab"],["\ubf45","\u1108\u1169\u11ac"],["\ubf46","\u1108\u1169\u11ad"],["\ubf47","\u1108\u1169\u11ae"],["\ubf48","\u1108\u1169\u11af"],["\ubf49","\u1108\u1169\u11b0"],["\ubf4a","\u1108\u1169\u11b1"],["\ubf4b","\u1108\u1169\u11b2"],["\ubf4c","\u1108\u1169\u11b3"],["\ubf4d","\u1108\u1169\u11b4"],["\ubf4e","\u1108\u1169\u11b5"],["\ubf4f","\u1108\u1169\u11b6"],["\ubf50","\u1108\u1169\u11b7"],["\ubf51","\u1108\u1169\u11b8"],["\ubf52","\u1108\u1169\u11b9"],["\ubf53","\u1108\u1169\u11ba"],["\ubf54","\u1108\u1169\u11bb"],["\ubf55","\u1108\u1169\u11bc"],["\ubf56","\u1108\u1169\u11bd"],["\ubf57","\u1108\u1169\u11be"],["\ubf58","\u1108\u1169\u11bf"],["\ubf59","\u1108\u1169\u11c0"],["\ubf5a","\u1108\u1169\u11c1"],["\ubf5b","\u1108\u1169\u11c2"],["\ubf5c","\u1108\u116a"],["\ubf5d","\u1108\u116a\u11a8"],["\ubf5e","\u1108\u116a\u11a9"],["\ubf5f","\u1108\u116a\u11aa"],["\ubf60","\u1108\u116a\u11ab"],["\ubf61","\u1108\u116a\u11ac"],["\ubf62","\u1108\u116a\u11ad"],["\ubf63","\u1108\u116a\u11ae"],["\ubf64","\u1108\u116a\u11af"],["\ubf65","\u1108\u116a\u11b0"],["\ubf66","\u1108\u116a\u11b1"],["\ubf67","\u1108\u116a\u11b2"],["\ubf68","\u1108\u116a\u11b3"],["\ubf69","\u1108\u116a\u11b4"],["\ubf6a","\u1108\u116a\u11b5"],["\ubf6b","\u1108\u116a\u11b6"],["\ubf6c","\u1108\u116a\u11b7"],["\ubf6d","\u1108\u116a\u11b8"],["\ubf6e","\u1108\u116a\u11b9"],["\ubf6f","\u1108\u116a\u11ba"],["\ubf70","\u1108\u116a\u11bb"],["\ubf71","\u1108\u116a\u11bc"],["\ubf72","\u1108\u116a\u11bd"],["\ubf73","\u1108\u116a\u11be"],["\ubf74","\u1108\u116a\u11bf"],["\ubf75","\u1108\u116a\u11c0"],["\ubf76","\u1108\u116a\u11c1"],["\ubf77","\u1108\u116a\u11c2"],["\ubf78","\u1108\u116b"],["\ubf79","\u1108\u116b\u11a8"],["\ubf7a","\u1108\u116b\u11a9"],["\ubf7b","\u1108\u116b\u11aa"],["\ubf7c","\u1108\u116b\u11ab"],["\ubf7d","\u1108\u116b\u11ac"],["\ubf7e","\u1108\u116b\u11ad"],["\ubf7f","\u1108\u116b\u11ae"],["\ubf80","\u1108\u116b\u11af"],["\ubf81","\u1108\u116b\u11b0"],["\ubf82","\u1108\u116b\u11b1"],["\ubf83","\u1108\u116b\u11b2"],["\ubf84","\u1108\u116b\u11b3"],["\ubf85","\u1108\u116b\u11b4"],["\ubf86","\u1108\u116b\u11b5"],["\ubf87","\u1108\u116b\u11b6"],["\ubf88","\u1108\u116b\u11b7"],["\ubf89","\u1108\u116b\u11b8"],["\ubf8a","\u1108\u116b\u11b9"],["\ubf8b","\u1108\u116b\u11ba"],["\ubf8c","\u1108\u116b\u11bb"],["\ubf8d","\u1108\u116b\u11bc"],["\ubf8e","\u1108\u116b\u11bd"],["\ubf8f","\u1108\u116b\u11be"],["\ubf90","\u1108\u116b\u11bf"],["\ubf91","\u1108\u116b\u11c0"],["\ubf92","\u1108\u116b\u11c1"],["\ubf93","\u1108\u116b\u11c2"],["\ubf94","\u1108\u116c"],["\ubf95","\u1108\u116c\u11a8"],["\ubf96","\u1108\u116c\u11a9"],["\ubf97","\u1108\u116c\u11aa"],["\ubf98","\u1108\u116c\u11ab"],["\ubf99","\u1108\u116c\u11ac"],["\ubf9a","\u1108\u116c\u11ad"],["\ubf9b","\u1108\u116c\u11ae"],["\ubf9c","\u1108\u116c\u11af"],["\ubf9d","\u1108\u116c\u11b0"],["\ubf9e","\u1108\u116c\u11b1"],["\ubf9f","\u1108\u116c\u11b2"],["\ubfa0","\u1108\u116c\u11b3"],["\ubfa1","\u1108\u116c\u11b4"],["\ubfa2","\u1108\u116c\u11b5"],["\ubfa3","\u1108\u116c\u11b6"],["\ubfa4","\u1108\u116c\u11b7"],["\ubfa5","\u1108\u116c\u11b8"],["\ubfa6","\u1108\u116c\u11b9"],["\ubfa7","\u1108\u116c\u11ba"],["\ubfa8","\u1108\u116c\u11bb"],["\ubfa9","\u1108\u116c\u11bc"],["\ubfaa","\u1108\u116c\u11bd"],["\ubfab","\u1108\u116c\u11be"],["\ubfac","\u1108\u116c\u11bf"],["\ubfad","\u1108\u116c\u11c0"],["\ubfae","\u1108\u116c\u11c1"],["\ubfaf","\u1108\u116c\u11c2"],["\ubfb0","\u1108\u116d"],["\ubfb1","\u1108\u116d\u11a8"],["\ubfb2","\u1108\u116d\u11a9"],["\ubfb3","\u1108\u116d\u11aa"],["\ubfb4","\u1108\u116d\u11ab"],["\ubfb5","\u1108\u116d\u11ac"],["\ubfb6","\u1108\u116d\u11ad"],["\ubfb7","\u1108\u116d\u11ae"],["\ubfb8","\u1108\u116d\u11af"],["\ubfb9","\u1108\u116d\u11b0"],["\ubfba","\u1108\u116d\u11b1"],["\ubfbb","\u1108\u116d\u11b2"],["\ubfbc","\u1108\u116d\u11b3"],["\ubfbd","\u1108\u116d\u11b4"],["\ubfbe","\u1108\u116d\u11b5"],["\ubfbf","\u1108\u116d\u11b6"],["\ubfc0","\u1108\u116d\u11b7"],["\ubfc1","\u1108\u116d\u11b8"],["\ubfc2","\u1108\u116d\u11b9"],["\ubfc3","\u1108\u116d\u11ba"],["\ubfc4","\u1108\u116d\u11bb"],["\ubfc5","\u1108\u116d\u11bc"],["\ubfc6","\u1108\u116d\u11bd"],["\ubfc7","\u1108\u116d\u11be"],["\ubfc8","\u1108\u116d\u11bf"],["\ubfc9","\u1108\u116d\u11c0"],["\ubfca","\u1108\u116d\u11c1"],["\ubfcb","\u1108\u116d\u11c2"],["\ubfcc","\u1108\u116e"],["\ubfcd","\u1108\u116e\u11a8"],["\ubfce","\u1108\u116e\u11a9"],["\ubfcf","\u1108\u116e\u11aa"],["\ubfd0","\u1108\u116e\u11ab"],["\ubfd1","\u1108\u116e\u11ac"],["\ubfd2","\u1108\u116e\u11ad"],["\ubfd3","\u1108\u116e\u11ae"],["\ubfd4","\u1108\u116e\u11af"],["\ubfd5","\u1108\u116e\u11b0"],["\ubfd6","\u1108\u116e\u11b1"],["\ubfd7","\u1108\u116e\u11b2"],["\ubfd8","\u1108\u116e\u11b3"],["\ubfd9","\u1108\u116e\u11b4"],["\ubfda","\u1108\u116e\u11b5"],["\ubfdb","\u1108\u116e\u11b6"],["\ubfdc","\u1108\u116e\u11b7"],["\ubfdd","\u1108\u116e\u11b8"],["\ubfde","\u1108\u116e\u11b9"],["\ubfdf","\u1108\u116e\u11ba"],["\ubfe0","\u1108\u116e\u11bb"],["\ubfe1","\u1108\u116e\u11bc"],["\ubfe2","\u1108\u116e\u11bd"],["\ubfe3","\u1108\u116e\u11be"],["\ubfe4","\u1108\u116e\u11bf"],["\ubfe5","\u1108\u116e\u11c0"],["\ubfe6","\u1108\u116e\u11c1"],["\ubfe7","\u1108\u116e\u11c2"],["\ubfe8","\u1108\u116f"],["\ubfe9","\u1108\u116f\u11a8"],["\ubfea","\u1108\u116f\u11a9"],["\ubfeb","\u1108\u116f\u11aa"],["\ubfec","\u1108\u116f\u11ab"],["\ubfed","\u1108\u116f\u11ac"],["\ubfee","\u1108\u116f\u11ad"],["\ubfef","\u1108\u116f\u11ae"],["\ubff0","\u1108\u116f\u11af"],["\ubff1","\u1108\u116f\u11b0"],["\ubff2","\u1108\u116f\u11b1"],["\ubff3","\u1108\u116f\u11b2"],["\ubff4","\u1108\u116f\u11b3"],["\ubff5","\u1108\u116f\u11b4"],["\ubff6","\u1108\u116f\u11b5"],["\ubff7","\u1108\u116f\u11b6"],["\ubff8","\u1108\u116f\u11b7"],["\ubff9","\u1108\u116f\u11b8"],["\ubffa","\u1108\u116f\u11b9"],["\ubffb","\u1108\u116f\u11ba"],["\ubffc","\u1108\u116f\u11bb"],["\ubffd","\u1108\u116f\u11bc"],["\ubffe","\u1108\u116f\u11bd"],["\ubfff","\u1108\u116f\u11be"],["\uc000","\u1108\u116f\u11bf"],["\uc001","\u1108\u116f\u11c0"],["\uc002","\u1108\u116f\u11c1"],["\uc003","\u1108\u116f\u11c2"],["\uc004","\u1108\u1170"],["\uc005","\u1108\u1170\u11a8"],["\uc006","\u1108\u1170\u11a9"],["\uc007","\u1108\u1170\u11aa"],["\uc008","\u1108\u1170\u11ab"],["\uc009","\u1108\u1170\u11ac"],["\uc00a","\u1108\u1170\u11ad"],["\uc00b","\u1108\u1170\u11ae"],["\uc00c","\u1108\u1170\u11af"],["\uc00d","\u1108\u1170\u11b0"],["\uc00e","\u1108\u1170\u11b1"],["\uc00f","\u1108\u1170\u11b2"],["\uc010","\u1108\u1170\u11b3"],["\uc011","\u1108\u1170\u11b4"],["\uc012","\u1108\u1170\u11b5"],["\uc013","\u1108\u1170\u11b6"],["\uc014","\u1108\u1170\u11b7"],["\uc015","\u1108\u1170\u11b8"],["\uc016","\u1108\u1170\u11b9"],["\uc017","\u1108\u1170\u11ba"],["\uc018","\u1108\u1170\u11bb"],["\uc019","\u1108\u1170\u11bc"],["\uc01a","\u1108\u1170\u11bd"],["\uc01b","\u1108\u1170\u11be"],["\uc01c","\u1108\u1170\u11bf"],["\uc01d","\u1108\u1170\u11c0"],["\uc01e","\u1108\u1170\u11c1"],["\uc01f","\u1108\u1170\u11c2"],["\uc020","\u1108\u1171"],["\uc021","\u1108\u1171\u11a8"],["\uc022","\u1108\u1171\u11a9"],["\uc023","\u1108\u1171\u11aa"],["\uc024","\u1108\u1171\u11ab"],["\uc025","\u1108\u1171\u11ac"],["\uc026","\u1108\u1171\u11ad"],["\uc027","\u1108\u1171\u11ae"],["\uc028","\u1108\u1171\u11af"],["\uc029","\u1108\u1171\u11b0"],["\uc02a","\u1108\u1171\u11b1"],["\uc02b","\u1108\u1171\u11b2"],["\uc02c","\u1108\u1171\u11b3"],["\uc02d","\u1108\u1171\u11b4"],["\uc02e","\u1108\u1171\u11b5"],["\uc02f","\u1108\u1171\u11b6"],["\uc030","\u1108\u1171\u11b7"],["\uc031","\u1108\u1171\u11b8"],["\uc032","\u1108\u1171\u11b9"],["\uc033","\u1108\u1171\u11ba"],["\uc034","\u1108\u1171\u11bb"],["\uc035","\u1108\u1171\u11bc"],["\uc036","\u1108\u1171\u11bd"],["\uc037","\u1108\u1171\u11be"],["\uc038","\u1108\u1171\u11bf"],["\uc039","\u1108\u1171\u11c0"],["\uc03a","\u1108\u1171\u11c1"],["\uc03b","\u1108\u1171\u11c2"],["\uc03c","\u1108\u1172"],["\uc03d","\u1108\u1172\u11a8"],["\uc03e","\u1108\u1172\u11a9"],["\uc03f","\u1108\u1172\u11aa"],["\uc040","\u1108\u1172\u11ab"],["\uc041","\u1108\u1172\u11ac"],["\uc042","\u1108\u1172\u11ad"],["\uc043","\u1108\u1172\u11ae"],["\uc044","\u1108\u1172\u11af"],["\uc045","\u1108\u1172\u11b0"],["\uc046","\u1108\u1172\u11b1"],["\uc047","\u1108\u1172\u11b2"],["\uc048","\u1108\u1172\u11b3"],["\uc049","\u1108\u1172\u11b4"],["\uc04a","\u1108\u1172\u11b5"],["\uc04b","\u1108\u1172\u11b6"],["\uc04c","\u1108\u1172\u11b7"],["\uc04d","\u1108\u1172\u11b8"],["\uc04e","\u1108\u1172\u11b9"],["\uc04f","\u1108\u1172\u11ba"],["\uc050","\u1108\u1172\u11bb"],["\uc051","\u1108\u1172\u11bc"],["\uc052","\u1108\u1172\u11bd"],["\uc053","\u1108\u1172\u11be"],["\uc054","\u1108\u1172\u11bf"],["\uc055","\u1108\u1172\u11c0"],["\uc056","\u1108\u1172\u11c1"],["\uc057","\u1108\u1172\u11c2"],["\uc058","\u1108\u1173"],["\uc059","\u1108\u1173\u11a8"],["\uc05a","\u1108\u1173\u11a9"],["\uc05b","\u1108\u1173\u11aa"],["\uc05c","\u1108\u1173\u11ab"],["\uc05d","\u1108\u1173\u11ac"],["\uc05e","\u1108\u1173\u11ad"],["\uc05f","\u1108\u1173\u11ae"],["\uc060","\u1108\u1173\u11af"],["\uc061","\u1108\u1173\u11b0"],["\uc062","\u1108\u1173\u11b1"],["\uc063","\u1108\u1173\u11b2"],["\uc064","\u1108\u1173\u11b3"],["\uc065","\u1108\u1173\u11b4"],["\uc066","\u1108\u1173\u11b5"],["\uc067","\u1108\u1173\u11b6"],["\uc068","\u1108\u1173\u11b7"],["\uc069","\u1108\u1173\u11b8"],["\uc06a","\u1108\u1173\u11b9"],["\uc06b","\u1108\u1173\u11ba"],["\uc06c","\u1108\u1173\u11bb"],["\uc06d","\u1108\u1173\u11bc"],["\uc06e","\u1108\u1173\u11bd"],["\uc06f","\u1108\u1173\u11be"],["\uc070","\u1108\u1173\u11bf"],["\uc071","\u1108\u1173\u11c0"],["\uc072","\u1108\u1173\u11c1"],["\uc073","\u1108\u1173\u11c2"],["\uc074","\u1108\u1174"],["\uc075","\u1108\u1174\u11a8"],["\uc076","\u1108\u1174\u11a9"],["\uc077","\u1108\u1174\u11aa"],["\uc078","\u1108\u1174\u11ab"],["\uc079","\u1108\u1174\u11ac"],["\uc07a","\u1108\u1174\u11ad"],["\uc07b","\u1108\u1174\u11ae"],["\uc07c","\u1108\u1174\u11af"],["\uc07d","\u1108\u1174\u11b0"],["\uc07e","\u1108\u1174\u11b1"],["\uc07f","\u1108\u1174\u11b2"],["\uc080","\u1108\u1174\u11b3"],["\uc081","\u1108\u1174\u11b4"],["\uc082","\u1108\u1174\u11b5"],["\uc083","\u1108\u1174\u11b6"],["\uc084","\u1108\u1174\u11b7"],["\uc085","\u1108\u1174\u11b8"],["\uc086","\u1108\u1174\u11b9"],["\uc087","\u1108\u1174\u11ba"],["\uc088","\u1108\u1174\u11bb"],["\uc089","\u1108\u1174\u11bc"],["\uc08a","\u1108\u1174\u11bd"],["\uc08b","\u1108\u1174\u11be"],["\uc08c","\u1108\u1174\u11bf"],["\uc08d","\u1108\u1174\u11c0"],["\uc08e","\u1108\u1174\u11c1"],["\uc08f","\u1108\u1174\u11c2"],["\uc090","\u1108\u1175"],["\uc091","\u1108\u1175\u11a8"],["\uc092","\u1108\u1175\u11a9"],["\uc093","\u1108\u1175\u11aa"],["\uc094","\u1108\u1175\u11ab"],["\uc095","\u1108\u1175\u11ac"],["\uc096","\u1108\u1175\u11ad"],["\uc097","\u1108\u1175\u11ae"],["\uc098","\u1108\u1175\u11af"],["\uc099","\u1108\u1175\u11b0"],["\uc09a","\u1108\u1175\u11b1"],["\uc09b","\u1108\u1175\u11b2"],["\uc09c","\u1108\u1175\u11b3"],["\uc09d","\u1108\u1175\u11b4"],["\uc09e","\u1108\u1175\u11b5"],["\uc09f","\u1108\u1175\u11b6"],["\uc0a0","\u1108\u1175\u11b7"],["\uc0a1","\u1108\u1175\u11b8"],["\uc0a2","\u1108\u1175\u11b9"],["\uc0a3","\u1108\u1175\u11ba"],["\uc0a4","\u1108\u1175\u11bb"],["\uc0a5","\u1108\u1175\u11bc"],["\uc0a6","\u1108\u1175\u11bd"],["\uc0a7","\u1108\u1175\u11be"],["\uc0a8","\u1108\u1175\u11bf"],["\uc0a9","\u1108\u1175\u11c0"],["\uc0aa","\u1108\u1175\u11c1"],["\uc0ab","\u1108\u1175\u11c2"],["\uc0ac","\u1109\u1161"],["\uc0ad","\u1109\u1161\u11a8"],["\uc0ae","\u1109\u1161\u11a9"],["\uc0af","\u1109\u1161\u11aa"],["\uc0b0","\u1109\u1161\u11ab"],["\uc0b1","\u1109\u1161\u11ac"],["\uc0b2","\u1109\u1161\u11ad"],["\uc0b3","\u1109\u1161\u11ae"],["\uc0b4","\u1109\u1161\u11af"],["\uc0b5","\u1109\u1161\u11b0"],["\uc0b6","\u1109\u1161\u11b1"],["\uc0b7","\u1109\u1161\u11b2"],["\uc0b8","\u1109\u1161\u11b3"],["\uc0b9","\u1109\u1161\u11b4"],["\uc0ba","\u1109\u1161\u11b5"],["\uc0bb","\u1109\u1161\u11b6"],["\uc0bc","\u1109\u1161\u11b7"],["\uc0bd","\u1109\u1161\u11b8"],["\uc0be","\u1109\u1161\u11b9"],["\uc0bf","\u1109\u1161\u11ba"],["\uc0c0","\u1109\u1161\u11bb"],["\uc0c1","\u1109\u1161\u11bc"],["\uc0c2","\u1109\u1161\u11bd"],["\uc0c3","\u1109\u1161\u11be"],["\uc0c4","\u1109\u1161\u11bf"],["\uc0c5","\u1109\u1161\u11c0"],["\uc0c6","\u1109\u1161\u11c1"],["\uc0c7","\u1109\u1161\u11c2"],["\uc0c8","\u1109\u1162"],["\uc0c9","\u1109\u1162\u11a8"],["\uc0ca","\u1109\u1162\u11a9"],["\uc0cb","\u1109\u1162\u11aa"],["\uc0cc","\u1109\u1162\u11ab"],["\uc0cd","\u1109\u1162\u11ac"],["\uc0ce","\u1109\u1162\u11ad"],["\uc0cf","\u1109\u1162\u11ae"],["\uc0d0","\u1109\u1162\u11af"],["\uc0d1","\u1109\u1162\u11b0"],["\uc0d2","\u1109\u1162\u11b1"],["\uc0d3","\u1109\u1162\u11b2"],["\uc0d4","\u1109\u1162\u11b3"],["\uc0d5","\u1109\u1162\u11b4"],["\uc0d6","\u1109\u1162\u11b5"],["\uc0d7","\u1109\u1162\u11b6"],["\uc0d8","\u1109\u1162\u11b7"],["\uc0d9","\u1109\u1162\u11b8"],["\uc0da","\u1109\u1162\u11b9"],["\uc0db","\u1109\u1162\u11ba"],["\uc0dc","\u1109\u1162\u11bb"],["\uc0dd","\u1109\u1162\u11bc"],["\uc0de","\u1109\u1162\u11bd"],["\uc0df","\u1109\u1162\u11be"],["\uc0e0","\u1109\u1162\u11bf"],["\uc0e1","\u1109\u1162\u11c0"],["\uc0e2","\u1109\u1162\u11c1"],["\uc0e3","\u1109\u1162\u11c2"],["\uc0e4","\u1109\u1163"],["\uc0e5","\u1109\u1163\u11a8"],["\uc0e6","\u1109\u1163\u11a9"],["\uc0e7","\u1109\u1163\u11aa"],["\uc0e8","\u1109\u1163\u11ab"],["\uc0e9","\u1109\u1163\u11ac"],["\uc0ea","\u1109\u1163\u11ad"],["\uc0eb","\u1109\u1163\u11ae"],["\uc0ec","\u1109\u1163\u11af"],["\uc0ed","\u1109\u1163\u11b0"],["\uc0ee","\u1109\u1163\u11b1"],["\uc0ef","\u1109\u1163\u11b2"],["\uc0f0","\u1109\u1163\u11b3"],["\uc0f1","\u1109\u1163\u11b4"],["\uc0f2","\u1109\u1163\u11b5"],["\uc0f3","\u1109\u1163\u11b6"],["\uc0f4","\u1109\u1163\u11b7"],["\uc0f5","\u1109\u1163\u11b8"],["\uc0f6","\u1109\u1163\u11b9"],["\uc0f7","\u1109\u1163\u11ba"],["\uc0f8","\u1109\u1163\u11bb"],["\uc0f9","\u1109\u1163\u11bc"],["\uc0fa","\u1109\u1163\u11bd"],["\uc0fb","\u1109\u1163\u11be"],["\uc0fc","\u1109\u1163\u11bf"],["\uc0fd","\u1109\u1163\u11c0"],["\uc0fe","\u1109\u1163\u11c1"],["\uc0ff","\u1109\u1163\u11c2"],["\uc100","\u1109\u1164"],["\uc101","\u1109\u1164\u11a8"],["\uc102","\u1109\u1164\u11a9"],["\uc103","\u1109\u1164\u11aa"],["\uc104","\u1109\u1164\u11ab"],["\uc105","\u1109\u1164\u11ac"],["\uc106","\u1109\u1164\u11ad"],["\uc107","\u1109\u1164\u11ae"],["\uc108","\u1109\u1164\u11af"],["\uc109","\u1109\u1164\u11b0"],["\uc10a","\u1109\u1164\u11b1"],["\uc10b","\u1109\u1164\u11b2"],["\uc10c","\u1109\u1164\u11b3"],["\uc10d","\u1109\u1164\u11b4"],["\uc10e","\u1109\u1164\u11b5"],["\uc10f","\u1109\u1164\u11b6"],["\uc110","\u1109\u1164\u11b7"],["\uc111","\u1109\u1164\u11b8"],["\uc112","\u1109\u1164\u11b9"],["\uc113","\u1109\u1164\u11ba"],["\uc114","\u1109\u1164\u11bb"],["\uc115","\u1109\u1164\u11bc"],["\uc116","\u1109\u1164\u11bd"],["\uc117","\u1109\u1164\u11be"],["\uc118","\u1109\u1164\u11bf"],["\uc119","\u1109\u1164\u11c0"],["\uc11a","\u1109\u1164\u11c1"],["\uc11b","\u1109\u1164\u11c2"],["\uc11c","\u1109\u1165"],["\uc11d","\u1109\u1165\u11a8"],["\uc11e","\u1109\u1165\u11a9"],["\uc11f","\u1109\u1165\u11aa"],["\uc120","\u1109\u1165\u11ab"],["\uc121","\u1109\u1165\u11ac"],["\uc122","\u1109\u1165\u11ad"],["\uc123","\u1109\u1165\u11ae"],["\uc124","\u1109\u1165\u11af"],["\uc125","\u1109\u1165\u11b0"],["\uc126","\u1109\u1165\u11b1"],["\uc127","\u1109\u1165\u11b2"],["\uc128","\u1109\u1165\u11b3"],["\uc129","\u1109\u1165\u11b4"],["\uc12a","\u1109\u1165\u11b5"],["\uc12b","\u1109\u1165\u11b6"],["\uc12c","\u1109\u1165\u11b7"],["\uc12d","\u1109\u1165\u11b8"],["\uc12e","\u1109\u1165\u11b9"],["\uc12f","\u1109\u1165\u11ba"],["\uc130","\u1109\u1165\u11bb"],["\uc131","\u1109\u1165\u11bc"],["\uc132","\u1109\u1165\u11bd"],["\uc133","\u1109\u1165\u11be"],["\uc134","\u1109\u1165\u11bf"],["\uc135","\u1109\u1165\u11c0"],["\uc136","\u1109\u1165\u11c1"],["\uc137","\u1109\u1165\u11c2"],["\uc138","\u1109\u1166"],["\uc139","\u1109\u1166\u11a8"],["\uc13a","\u1109\u1166\u11a9"],["\uc13b","\u1109\u1166\u11aa"],["\uc13c","\u1109\u1166\u11ab"],["\uc13d","\u1109\u1166\u11ac"],["\uc13e","\u1109\u1166\u11ad"],["\uc13f","\u1109\u1166\u11ae"],["\uc140","\u1109\u1166\u11af"],["\uc141","\u1109\u1166\u11b0"],["\uc142","\u1109\u1166\u11b1"],["\uc143","\u1109\u1166\u11b2"],["\uc144","\u1109\u1166\u11b3"],["\uc145","\u1109\u1166\u11b4"],["\uc146","\u1109\u1166\u11b5"],["\uc147","\u1109\u1166\u11b6"],["\uc148","\u1109\u1166\u11b7"],["\uc149","\u1109\u1166\u11b8"],["\uc14a","\u1109\u1166\u11b9"],["\uc14b","\u1109\u1166\u11ba"],["\uc14c","\u1109\u1166\u11bb"],["\uc14d","\u1109\u1166\u11bc"],["\uc14e","\u1109\u1166\u11bd"],["\uc14f","\u1109\u1166\u11be"],["\uc150","\u1109\u1166\u11bf"],["\uc151","\u1109\u1166\u11c0"],["\uc152","\u1109\u1166\u11c1"],["\uc153","\u1109\u1166\u11c2"],["\uc154","\u1109\u1167"],["\uc155","\u1109\u1167\u11a8"],["\uc156","\u1109\u1167\u11a9"],["\uc157","\u1109\u1167\u11aa"],["\uc158","\u1109\u1167\u11ab"],["\uc159","\u1109\u1167\u11ac"],["\uc15a","\u1109\u1167\u11ad"],["\uc15b","\u1109\u1167\u11ae"],["\uc15c","\u1109\u1167\u11af"],["\uc15d","\u1109\u1167\u11b0"],["\uc15e","\u1109\u1167\u11b1"],["\uc15f","\u1109\u1167\u11b2"],["\uc160","\u1109\u1167\u11b3"],["\uc161","\u1109\u1167\u11b4"],["\uc162","\u1109\u1167\u11b5"],["\uc163","\u1109\u1167\u11b6"],["\uc164","\u1109\u1167\u11b7"],["\uc165","\u1109\u1167\u11b8"],["\uc166","\u1109\u1167\u11b9"],["\uc167","\u1109\u1167\u11ba"],["\uc168","\u1109\u1167\u11bb"],["\uc169","\u1109\u1167\u11bc"],["\uc16a","\u1109\u1167\u11bd"],["\uc16b","\u1109\u1167\u11be"],["\uc16c","\u1109\u1167\u11bf"],["\uc16d","\u1109\u1167\u11c0"],["\uc16e","\u1109\u1167\u11c1"],["\uc16f","\u1109\u1167\u11c2"],["\uc170","\u1109\u1168"],["\uc171","\u1109\u1168\u11a8"],["\uc172","\u1109\u1168\u11a9"],["\uc173","\u1109\u1168\u11aa"],["\uc174","\u1109\u1168\u11ab"],["\uc175","\u1109\u1168\u11ac"],["\uc176","\u1109\u1168\u11ad"],["\uc177","\u1109\u1168\u11ae"],["\uc178","\u1109\u1168\u11af"],["\uc179","\u1109\u1168\u11b0"],["\uc17a","\u1109\u1168\u11b1"],["\uc17b","\u1109\u1168\u11b2"],["\uc17c","\u1109\u1168\u11b3"],["\uc17d","\u1109\u1168\u11b4"],["\uc17e","\u1109\u1168\u11b5"],["\uc17f","\u1109\u1168\u11b6"],["\uc180","\u1109\u1168\u11b7"],["\uc181","\u1109\u1168\u11b8"],["\uc182","\u1109\u1168\u11b9"],["\uc183","\u1109\u1168\u11ba"],["\uc184","\u1109\u1168\u11bb"],["\uc185","\u1109\u1168\u11bc"],["\uc186","\u1109\u1168\u11bd"],["\uc187","\u1109\u1168\u11be"],["\uc188","\u1109\u1168\u11bf"],["\uc189","\u1109\u1168\u11c0"],["\uc18a","\u1109\u1168\u11c1"],["\uc18b","\u1109\u1168\u11c2"],["\uc18c","\u1109\u1169"],["\uc18d","\u1109\u1169\u11a8"],["\uc18e","\u1109\u1169\u11a9"],["\uc18f","\u1109\u1169\u11aa"],["\uc190","\u1109\u1169\u11ab"],["\uc191","\u1109\u1169\u11ac"],["\uc192","\u1109\u1169\u11ad"],["\uc193","\u1109\u1169\u11ae"],["\uc194","\u1109\u1169\u11af"],["\uc195","\u1109\u1169\u11b0"],["\uc196","\u1109\u1169\u11b1"],["\uc197","\u1109\u1169\u11b2"],["\uc198","\u1109\u1169\u11b3"],["\uc199","\u1109\u1169\u11b4"],["\uc19a","\u1109\u1169\u11b5"],["\uc19b","\u1109\u1169\u11b6"],["\uc19c","\u1109\u1169\u11b7"],["\uc19d","\u1109\u1169\u11b8"],["\uc19e","\u1109\u1169\u11b9"],["\uc19f","\u1109\u1169\u11ba"],["\uc1a0","\u1109\u1169\u11bb"],["\uc1a1","\u1109\u1169\u11bc"],["\uc1a2","\u1109\u1169\u11bd"],["\uc1a3","\u1109\u1169\u11be"],["\uc1a4","\u1109\u1169\u11bf"],["\uc1a5","\u1109\u1169\u11c0"],["\uc1a6","\u1109\u1169\u11c1"],["\uc1a7","\u1109\u1169\u11c2"],["\uc1a8","\u1109\u116a"],["\uc1a9","\u1109\u116a\u11a8"],["\uc1aa","\u1109\u116a\u11a9"],["\uc1ab","\u1109\u116a\u11aa"],["\uc1ac","\u1109\u116a\u11ab"],["\uc1ad","\u1109\u116a\u11ac"],["\uc1ae","\u1109\u116a\u11ad"],["\uc1af","\u1109\u116a\u11ae"],["\uc1b0","\u1109\u116a\u11af"],["\uc1b1","\u1109\u116a\u11b0"],["\uc1b2","\u1109\u116a\u11b1"],["\uc1b3","\u1109\u116a\u11b2"],["\uc1b4","\u1109\u116a\u11b3"],["\uc1b5","\u1109\u116a\u11b4"],["\uc1b6","\u1109\u116a\u11b5"],["\uc1b7","\u1109\u116a\u11b6"],["\uc1b8","\u1109\u116a\u11b7"],["\uc1b9","\u1109\u116a\u11b8"],["\uc1ba","\u1109\u116a\u11b9"],["\uc1bb","\u1109\u116a\u11ba"],["\uc1bc","\u1109\u116a\u11bb"],["\uc1bd","\u1109\u116a\u11bc"],["\uc1be","\u1109\u116a\u11bd"],["\uc1bf","\u1109\u116a\u11be"],["\uc1c0","\u1109\u116a\u11bf"],["\uc1c1","\u1109\u116a\u11c0"],["\uc1c2","\u1109\u116a\u11c1"],["\uc1c3","\u1109\u116a\u11c2"],["\uc1c4","\u1109\u116b"],["\uc1c5","\u1109\u116b\u11a8"],["\uc1c6","\u1109\u116b\u11a9"],["\uc1c7","\u1109\u116b\u11aa"],["\uc1c8","\u1109\u116b\u11ab"],["\uc1c9","\u1109\u116b\u11ac"],["\uc1ca","\u1109\u116b\u11ad"],["\uc1cb","\u1109\u116b\u11ae"],["\uc1cc","\u1109\u116b\u11af"],["\uc1cd","\u1109\u116b\u11b0"],["\uc1ce","\u1109\u116b\u11b1"],["\uc1cf","\u1109\u116b\u11b2"],["\uc1d0","\u1109\u116b\u11b3"],["\uc1d1","\u1109\u116b\u11b4"],["\uc1d2","\u1109\u116b\u11b5"],["\uc1d3","\u1109\u116b\u11b6"],["\uc1d4","\u1109\u116b\u11b7"],["\uc1d5","\u1109\u116b\u11b8"],["\uc1d6","\u1109\u116b\u11b9"],["\uc1d7","\u1109\u116b\u11ba"],["\uc1d8","\u1109\u116b\u11bb"],["\uc1d9","\u1109\u116b\u11bc"],["\uc1da","\u1109\u116b\u11bd"],["\uc1db","\u1109\u116b\u11be"],["\uc1dc","\u1109\u116b\u11bf"],["\uc1dd","\u1109\u116b\u11c0"],["\uc1de","\u1109\u116b\u11c1"],["\uc1df","\u1109\u116b\u11c2"],["\uc1e0","\u1109\u116c"],["\uc1e1","\u1109\u116c\u11a8"],["\uc1e2","\u1109\u116c\u11a9"],["\uc1e3","\u1109\u116c\u11aa"],["\uc1e4","\u1109\u116c\u11ab"],["\uc1e5","\u1109\u116c\u11ac"],["\uc1e6","\u1109\u116c\u11ad"],["\uc1e7","\u1109\u116c\u11ae"],["\uc1e8","\u1109\u116c\u11af"],["\uc1e9","\u1109\u116c\u11b0"],["\uc1ea","\u1109\u116c\u11b1"],["\uc1eb","\u1109\u116c\u11b2"],["\uc1ec","\u1109\u116c\u11b3"],["\uc1ed","\u1109\u116c\u11b4"],["\uc1ee","\u1109\u116c\u11b5"],["\uc1ef","\u1109\u116c\u11b6"],["\uc1f0","\u1109\u116c\u11b7"],["\uc1f1","\u1109\u116c\u11b8"],["\uc1f2","\u1109\u116c\u11b9"],["\uc1f3","\u1109\u116c\u11ba"],["\uc1f4","\u1109\u116c\u11bb"],["\uc1f5","\u1109\u116c\u11bc"],["\uc1f6","\u1109\u116c\u11bd"],["\uc1f7","\u1109\u116c\u11be"],["\uc1f8","\u1109\u116c\u11bf"],["\uc1f9","\u1109\u116c\u11c0"],["\uc1fa","\u1109\u116c\u11c1"],["\uc1fb","\u1109\u116c\u11c2"],["\uc1fc","\u1109\u116d"],["\uc1fd","\u1109\u116d\u11a8"],["\uc1fe","\u1109\u116d\u11a9"],["\uc1ff","\u1109\u116d\u11aa"],["\uc200","\u1109\u116d\u11ab"],["\uc201","\u1109\u116d\u11ac"],["\uc202","\u1109\u116d\u11ad"],["\uc203","\u1109\u116d\u11ae"],["\uc204","\u1109\u116d\u11af"],["\uc205","\u1109\u116d\u11b0"],["\uc206","\u1109\u116d\u11b1"],["\uc207","\u1109\u116d\u11b2"],["\uc208","\u1109\u116d\u11b3"],["\uc209","\u1109\u116d\u11b4"],["\uc20a","\u1109\u116d\u11b5"],["\uc20b","\u1109\u116d\u11b6"],["\uc20c","\u1109\u116d\u11b7"],["\uc20d","\u1109\u116d\u11b8"],["\uc20e","\u1109\u116d\u11b9"],["\uc20f","\u1109\u116d\u11ba"],["\uc210","\u1109\u116d\u11bb"],["\uc211","\u1109\u116d\u11bc"],["\uc212","\u1109\u116d\u11bd"],["\uc213","\u1109\u116d\u11be"],["\uc214","\u1109\u116d\u11bf"],["\uc215","\u1109\u116d\u11c0"],["\uc216","\u1109\u116d\u11c1"],["\uc217","\u1109\u116d\u11c2"],["\uc218","\u1109\u116e"],["\uc219","\u1109\u116e\u11a8"],["\uc21a","\u1109\u116e\u11a9"],["\uc21b","\u1109\u116e\u11aa"],["\uc21c","\u1109\u116e\u11ab"],["\uc21d","\u1109\u116e\u11ac"],["\uc21e","\u1109\u116e\u11ad"],["\uc21f","\u1109\u116e\u11ae"],["\uc220","\u1109\u116e\u11af"],["\uc221","\u1109\u116e\u11b0"],["\uc222","\u1109\u116e\u11b1"],["\uc223","\u1109\u116e\u11b2"],["\uc224","\u1109\u116e\u11b3"],["\uc225","\u1109\u116e\u11b4"],["\uc226","\u1109\u116e\u11b5"],["\uc227","\u1109\u116e\u11b6"],["\uc228","\u1109\u116e\u11b7"],["\uc229","\u1109\u116e\u11b8"],["\uc22a","\u1109\u116e\u11b9"],["\uc22b","\u1109\u116e\u11ba"],["\uc22c","\u1109\u116e\u11bb"],["\uc22d","\u1109\u116e\u11bc"],["\uc22e","\u1109\u116e\u11bd"],["\uc22f","\u1109\u116e\u11be"],["\uc230","\u1109\u116e\u11bf"],["\uc231","\u1109\u116e\u11c0"],["\uc232","\u1109\u116e\u11c1"],["\uc233","\u1109\u116e\u11c2"],["\uc234","\u1109\u116f"],["\uc235","\u1109\u116f\u11a8"],["\uc236","\u1109\u116f\u11a9"],["\uc237","\u1109\u116f\u11aa"],["\uc238","\u1109\u116f\u11ab"],["\uc239","\u1109\u116f\u11ac"],["\uc23a","\u1109\u116f\u11ad"],["\uc23b","\u1109\u116f\u11ae"],["\uc23c","\u1109\u116f\u11af"],["\uc23d","\u1109\u116f\u11b0"],["\uc23e","\u1109\u116f\u11b1"],["\uc23f","\u1109\u116f\u11b2"],["\uc240","\u1109\u116f\u11b3"],["\uc241","\u1109\u116f\u11b4"],["\uc242","\u1109\u116f\u11b5"],["\uc243","\u1109\u116f\u11b6"],["\uc244","\u1109\u116f\u11b7"],["\uc245","\u1109\u116f\u11b8"],["\uc246","\u1109\u116f\u11b9"],["\uc247","\u1109\u116f\u11ba"],["\uc248","\u1109\u116f\u11bb"],["\uc249","\u1109\u116f\u11bc"],["\uc24a","\u1109\u116f\u11bd"],["\uc24b","\u1109\u116f\u11be"],["\uc24c","\u1109\u116f\u11bf"],["\uc24d","\u1109\u116f\u11c0"],["\uc24e","\u1109\u116f\u11c1"],["\uc24f","\u1109\u116f\u11c2"],["\uc250","\u1109\u1170"],["\uc251","\u1109\u1170\u11a8"],["\uc252","\u1109\u1170\u11a9"],["\uc253","\u1109\u1170\u11aa"],["\uc254","\u1109\u1170\u11ab"],["\uc255","\u1109\u1170\u11ac"],["\uc256","\u1109\u1170\u11ad"],["\uc257","\u1109\u1170\u11ae"],["\uc258","\u1109\u1170\u11af"],["\uc259","\u1109\u1170\u11b0"],["\uc25a","\u1109\u1170\u11b1"],["\uc25b","\u1109\u1170\u11b2"],["\uc25c","\u1109\u1170\u11b3"],["\uc25d","\u1109\u1170\u11b4"],["\uc25e","\u1109\u1170\u11b5"],["\uc25f","\u1109\u1170\u11b6"],["\uc260","\u1109\u1170\u11b7"],["\uc261","\u1109\u1170\u11b8"],["\uc262","\u1109\u1170\u11b9"],["\uc263","\u1109\u1170\u11ba"],["\uc264","\u1109\u1170\u11bb"],["\uc265","\u1109\u1170\u11bc"],["\uc266","\u1109\u1170\u11bd"],["\uc267","\u1109\u1170\u11be"],["\uc268","\u1109\u1170\u11bf"],["\uc269","\u1109\u1170\u11c0"],["\uc26a","\u1109\u1170\u11c1"],["\uc26b","\u1109\u1170\u11c2"],["\uc26c","\u1109\u1171"],["\uc26d","\u1109\u1171\u11a8"],["\uc26e","\u1109\u1171\u11a9"],["\uc26f","\u1109\u1171\u11aa"],["\uc270","\u1109\u1171\u11ab"],["\uc271","\u1109\u1171\u11ac"],["\uc272","\u1109\u1171\u11ad"],["\uc273","\u1109\u1171\u11ae"],["\uc274","\u1109\u1171\u11af"],["\uc275","\u1109\u1171\u11b0"],["\uc276","\u1109\u1171\u11b1"],["\uc277","\u1109\u1171\u11b2"],["\uc278","\u1109\u1171\u11b3"],["\uc279","\u1109\u1171\u11b4"],["\uc27a","\u1109\u1171\u11b5"],["\uc27b","\u1109\u1171\u11b6"],["\uc27c","\u1109\u1171\u11b7"],["\uc27d","\u1109\u1171\u11b8"],["\uc27e","\u1109\u1171\u11b9"],["\uc27f","\u1109\u1171\u11ba"],["\uc280","\u1109\u1171\u11bb"],["\uc281","\u1109\u1171\u11bc"],["\uc282","\u1109\u1171\u11bd"],["\uc283","\u1109\u1171\u11be"],["\uc284","\u1109\u1171\u11bf"],["\uc285","\u1109\u1171\u11c0"],["\uc286","\u1109\u1171\u11c1"],["\uc287","\u1109\u1171\u11c2"],["\uc288","\u1109\u1172"],["\uc289","\u1109\u1172\u11a8"],["\uc28a","\u1109\u1172\u11a9"],["\uc28b","\u1109\u1172\u11aa"],["\uc28c","\u1109\u1172\u11ab"],["\uc28d","\u1109\u1172\u11ac"],["\uc28e","\u1109\u1172\u11ad"],["\uc28f","\u1109\u1172\u11ae"],["\uc290","\u1109\u1172\u11af"],["\uc291","\u1109\u1172\u11b0"],["\uc292","\u1109\u1172\u11b1"],["\uc293","\u1109\u1172\u11b2"],["\uc294","\u1109\u1172\u11b3"],["\uc295","\u1109\u1172\u11b4"],["\uc296","\u1109\u1172\u11b5"],["\uc297","\u1109\u1172\u11b6"],["\uc298","\u1109\u1172\u11b7"],["\uc299","\u1109\u1172\u11b8"],["\uc29a","\u1109\u1172\u11b9"],["\uc29b","\u1109\u1172\u11ba"],["\uc29c","\u1109\u1172\u11bb"],["\uc29d","\u1109\u1172\u11bc"],["\uc29e","\u1109\u1172\u11bd"],["\uc29f","\u1109\u1172\u11be"],["\uc2a0","\u1109\u1172\u11bf"],["\uc2a1","\u1109\u1172\u11c0"],["\uc2a2","\u1109\u1172\u11c1"],["\uc2a3","\u1109\u1172\u11c2"],["\uc2a4","\u1109\u1173"],["\uc2a5","\u1109\u1173\u11a8"],["\uc2a6","\u1109\u1173\u11a9"],["\uc2a7","\u1109\u1173\u11aa"],["\uc2a8","\u1109\u1173\u11ab"],["\uc2a9","\u1109\u1173\u11ac"],["\uc2aa","\u1109\u1173\u11ad"],["\uc2ab","\u1109\u1173\u11ae"],["\uc2ac","\u1109\u1173\u11af"],["\uc2ad","\u1109\u1173\u11b0"],["\uc2ae","\u1109\u1173\u11b1"],["\uc2af","\u1109\u1173\u11b2"],["\uc2b0","\u1109\u1173\u11b3"],["\uc2b1","\u1109\u1173\u11b4"],["\uc2b2","\u1109\u1173\u11b5"],["\uc2b3","\u1109\u1173\u11b6"],["\uc2b4","\u1109\u1173\u11b7"],["\uc2b5","\u1109\u1173\u11b8"],["\uc2b6","\u1109\u1173\u11b9"],["\uc2b7","\u1109\u1173\u11ba"],["\uc2b8","\u1109\u1173\u11bb"],["\uc2b9","\u1109\u1173\u11bc"],["\uc2ba","\u1109\u1173\u11bd"],["\uc2bb","\u1109\u1173\u11be"],["\uc2bc","\u1109\u1173\u11bf"],["\uc2bd","\u1109\u1173\u11c0"],["\uc2be","\u1109\u1173\u11c1"],["\uc2bf","\u1109\u1173\u11c2"],["\uc2c0","\u1109\u1174"],["\uc2c1","\u1109\u1174\u11a8"],["\uc2c2","\u1109\u1174\u11a9"],["\uc2c3","\u1109\u1174\u11aa"],["\uc2c4","\u1109\u1174\u11ab"],["\uc2c5","\u1109\u1174\u11ac"],["\uc2c6","\u1109\u1174\u11ad"],["\uc2c7","\u1109\u1174\u11ae"],["\uc2c8","\u1109\u1174\u11af"],["\uc2c9","\u1109\u1174\u11b0"],["\uc2ca","\u1109\u1174\u11b1"],["\uc2cb","\u1109\u1174\u11b2"],["\uc2cc","\u1109\u1174\u11b3"],["\uc2cd","\u1109\u1174\u11b4"],["\uc2ce","\u1109\u1174\u11b5"],["\uc2cf","\u1109\u1174\u11b6"],["\uc2d0","\u1109\u1174\u11b7"],["\uc2d1","\u1109\u1174\u11b8"],["\uc2d2","\u1109\u1174\u11b9"],["\uc2d3","\u1109\u1174\u11ba"],["\uc2d4","\u1109\u1174\u11bb"],["\uc2d5","\u1109\u1174\u11bc"],["\uc2d6","\u1109\u1174\u11bd"],["\uc2d7","\u1109\u1174\u11be"],["\uc2d8","\u1109\u1174\u11bf"],["\uc2d9","\u1109\u1174\u11c0"],["\uc2da","\u1109\u1174\u11c1"],["\uc2db","\u1109\u1174\u11c2"],["\uc2dc","\u1109\u1175"],["\uc2dd","\u1109\u1175\u11a8"],["\uc2de","\u1109\u1175\u11a9"],["\uc2df","\u1109\u1175\u11aa"],["\uc2e0","\u1109\u1175\u11ab"],["\uc2e1","\u1109\u1175\u11ac"],["\uc2e2","\u1109\u1175\u11ad"],["\uc2e3","\u1109\u1175\u11ae"],["\uc2e4","\u1109\u1175\u11af"],["\uc2e5","\u1109\u1175\u11b0"],["\uc2e6","\u1109\u1175\u11b1"],["\uc2e7","\u1109\u1175\u11b2"],["\uc2e8","\u1109\u1175\u11b3"],["\uc2e9","\u1109\u1175\u11b4"],["\uc2ea","\u1109\u1175\u11b5"],["\uc2eb","\u1109\u1175\u11b6"],["\uc2ec","\u1109\u1175\u11b7"],["\uc2ed","\u1109\u1175\u11b8"],["\uc2ee","\u1109\u1175\u11b9"],["\uc2ef","\u1109\u1175\u11ba"],["\uc2f0","\u1109\u1175\u11bb"],["\uc2f1","\u1109\u1175\u11bc"],["\uc2f2","\u1109\u1175\u11bd"],["\uc2f3","\u1109\u1175\u11be"],["\uc2f4","\u1109\u1175\u11bf"],["\uc2f5","\u1109\u1175\u11c0"],["\uc2f6","\u1109\u1175\u11c1"],["\uc2f7","\u1109\u1175\u11c2"],["\uc2f8","\u110a\u1161"],["\uc2f9","\u110a\u1161\u11a8"],["\uc2fa","\u110a\u1161\u11a9"],["\uc2fb","\u110a\u1161\u11aa"],["\uc2fc","\u110a\u1161\u11ab"],["\uc2fd","\u110a\u1161\u11ac"],["\uc2fe","\u110a\u1161\u11ad"],["\uc2ff","\u110a\u1161\u11ae"],["\uc300","\u110a\u1161\u11af"],["\uc301","\u110a\u1161\u11b0"],["\uc302","\u110a\u1161\u11b1"],["\uc303","\u110a\u1161\u11b2"],["\uc304","\u110a\u1161\u11b3"],["\uc305","\u110a\u1161\u11b4"],["\uc306","\u110a\u1161\u11b5"],["\uc307","\u110a\u1161\u11b6"],["\uc308","\u110a\u1161\u11b7"],["\uc309","\u110a\u1161\u11b8"],["\uc30a","\u110a\u1161\u11b9"],["\uc30b","\u110a\u1161\u11ba"],["\uc30c","\u110a\u1161\u11bb"],["\uc30d","\u110a\u1161\u11bc"],["\uc30e","\u110a\u1161\u11bd"],["\uc30f","\u110a\u1161\u11be"],["\uc310","\u110a\u1161\u11bf"],["\uc311","\u110a\u1161\u11c0"],["\uc312","\u110a\u1161\u11c1"],["\uc313","\u110a\u1161\u11c2"],["\uc314","\u110a\u1162"],["\uc315","\u110a\u1162\u11a8"],["\uc316","\u110a\u1162\u11a9"],["\uc317","\u110a\u1162\u11aa"],["\uc318","\u110a\u1162\u11ab"],["\uc319","\u110a\u1162\u11ac"],["\uc31a","\u110a\u1162\u11ad"],["\uc31b","\u110a\u1162\u11ae"],["\uc31c","\u110a\u1162\u11af"],["\uc31d","\u110a\u1162\u11b0"],["\uc31e","\u110a\u1162\u11b1"],["\uc31f","\u110a\u1162\u11b2"],["\uc320","\u110a\u1162\u11b3"],["\uc321","\u110a\u1162\u11b4"],["\uc322","\u110a\u1162\u11b5"],["\uc323","\u110a\u1162\u11b6"],["\uc324","\u110a\u1162\u11b7"],["\uc325","\u110a\u1162\u11b8"],["\uc326","\u110a\u1162\u11b9"],["\uc327","\u110a\u1162\u11ba"],["\uc328","\u110a\u1162\u11bb"],["\uc329","\u110a\u1162\u11bc"],["\uc32a","\u110a\u1162\u11bd"],["\uc32b","\u110a\u1162\u11be"],["\uc32c","\u110a\u1162\u11bf"],["\uc32d","\u110a\u1162\u11c0"],["\uc32e","\u110a\u1162\u11c1"],["\uc32f","\u110a\u1162\u11c2"],["\uc330","\u110a\u1163"],["\uc331","\u110a\u1163\u11a8"],["\uc332","\u110a\u1163\u11a9"],["\uc333","\u110a\u1163\u11aa"],["\uc334","\u110a\u1163\u11ab"],["\uc335","\u110a\u1163\u11ac"],["\uc336","\u110a\u1163\u11ad"],["\uc337","\u110a\u1163\u11ae"],["\uc338","\u110a\u1163\u11af"],["\uc339","\u110a\u1163\u11b0"],["\uc33a","\u110a\u1163\u11b1"],["\uc33b","\u110a\u1163\u11b2"],["\uc33c","\u110a\u1163\u11b3"],["\uc33d","\u110a\u1163\u11b4"],["\uc33e","\u110a\u1163\u11b5"],["\uc33f","\u110a\u1163\u11b6"],["\uc340","\u110a\u1163\u11b7"],["\uc341","\u110a\u1163\u11b8"],["\uc342","\u110a\u1163\u11b9"],["\uc343","\u110a\u1163\u11ba"],["\uc344","\u110a\u1163\u11bb"],["\uc345","\u110a\u1163\u11bc"],["\uc346","\u110a\u1163\u11bd"],["\uc347","\u110a\u1163\u11be"],["\uc348","\u110a\u1163\u11bf"],["\uc349","\u110a\u1163\u11c0"],["\uc34a","\u110a\u1163\u11c1"],["\uc34b","\u110a\u1163\u11c2"],["\uc34c","\u110a\u1164"],["\uc34d","\u110a\u1164\u11a8"],["\uc34e","\u110a\u1164\u11a9"],["\uc34f","\u110a\u1164\u11aa"],["\uc350","\u110a\u1164\u11ab"],["\uc351","\u110a\u1164\u11ac"],["\uc352","\u110a\u1164\u11ad"],["\uc353","\u110a\u1164\u11ae"],["\uc354","\u110a\u1164\u11af"],["\uc355","\u110a\u1164\u11b0"],["\uc356","\u110a\u1164\u11b1"],["\uc357","\u110a\u1164\u11b2"],["\uc358","\u110a\u1164\u11b3"],["\uc359","\u110a\u1164\u11b4"],["\uc35a","\u110a\u1164\u11b5"],["\uc35b","\u110a\u1164\u11b6"],["\uc35c","\u110a\u1164\u11b7"],["\uc35d","\u110a\u1164\u11b8"],["\uc35e","\u110a\u1164\u11b9"],["\uc35f","\u110a\u1164\u11ba"],["\uc360","\u110a\u1164\u11bb"],["\uc361","\u110a\u1164\u11bc"],["\uc362","\u110a\u1164\u11bd"],["\uc363","\u110a\u1164\u11be"],["\uc364","\u110a\u1164\u11bf"],["\uc365","\u110a\u1164\u11c0"],["\uc366","\u110a\u1164\u11c1"],["\uc367","\u110a\u1164\u11c2"],["\uc368","\u110a\u1165"],["\uc369","\u110a\u1165\u11a8"],["\uc36a","\u110a\u1165\u11a9"],["\uc36b","\u110a\u1165\u11aa"],["\uc36c","\u110a\u1165\u11ab"],["\uc36d","\u110a\u1165\u11ac"],["\uc36e","\u110a\u1165\u11ad"],["\uc36f","\u110a\u1165\u11ae"],["\uc370","\u110a\u1165\u11af"],["\uc371","\u110a\u1165\u11b0"],["\uc372","\u110a\u1165\u11b1"],["\uc373","\u110a\u1165\u11b2"],["\uc374","\u110a\u1165\u11b3"],["\uc375","\u110a\u1165\u11b4"],["\uc376","\u110a\u1165\u11b5"],["\uc377","\u110a\u1165\u11b6"],["\uc378","\u110a\u1165\u11b7"],["\uc379","\u110a\u1165\u11b8"],["\uc37a","\u110a\u1165\u11b9"],["\uc37b","\u110a\u1165\u11ba"],["\uc37c","\u110a\u1165\u11bb"],["\uc37d","\u110a\u1165\u11bc"],["\uc37e","\u110a\u1165\u11bd"],["\uc37f","\u110a\u1165\u11be"],["\uc380","\u110a\u1165\u11bf"],["\uc381","\u110a\u1165\u11c0"],["\uc382","\u110a\u1165\u11c1"],["\uc383","\u110a\u1165\u11c2"],["\uc384","\u110a\u1166"],["\uc385","\u110a\u1166\u11a8"],["\uc386","\u110a\u1166\u11a9"],["\uc387","\u110a\u1166\u11aa"],["\uc388","\u110a\u1166\u11ab"],["\uc389","\u110a\u1166\u11ac"],["\uc38a","\u110a\u1166\u11ad"],["\uc38b","\u110a\u1166\u11ae"],["\uc38c","\u110a\u1166\u11af"],["\uc38d","\u110a\u1166\u11b0"],["\uc38e","\u110a\u1166\u11b1"],["\uc38f","\u110a\u1166\u11b2"],["\uc390","\u110a\u1166\u11b3"],["\uc391","\u110a\u1166\u11b4"],["\uc392","\u110a\u1166\u11b5"],["\uc393","\u110a\u1166\u11b6"],["\uc394","\u110a\u1166\u11b7"],["\uc395","\u110a\u1166\u11b8"],["\uc396","\u110a\u1166\u11b9"],["\uc397","\u110a\u1166\u11ba"],["\uc398","\u110a\u1166\u11bb"],["\uc399","\u110a\u1166\u11bc"],["\uc39a","\u110a\u1166\u11bd"],["\uc39b","\u110a\u1166\u11be"],["\uc39c","\u110a\u1166\u11bf"],["\uc39d","\u110a\u1166\u11c0"],["\uc39e","\u110a\u1166\u11c1"],["\uc39f","\u110a\u1166\u11c2"],["\uc3a0","\u110a\u1167"],["\uc3a1","\u110a\u1167\u11a8"],["\uc3a2","\u110a\u1167\u11a9"],["\uc3a3","\u110a\u1167\u11aa"],["\uc3a4","\u110a\u1167\u11ab"],["\uc3a5","\u110a\u1167\u11ac"],["\uc3a6","\u110a\u1167\u11ad"],["\uc3a7","\u110a\u1167\u11ae"],["\uc3a8","\u110a\u1167\u11af"],["\uc3a9","\u110a\u1167\u11b0"],["\uc3aa","\u110a\u1167\u11b1"],["\uc3ab","\u110a\u1167\u11b2"],["\uc3ac","\u110a\u1167\u11b3"],["\uc3ad","\u110a\u1167\u11b4"],["\uc3ae","\u110a\u1167\u11b5"],["\uc3af","\u110a\u1167\u11b6"],["\uc3b0","\u110a\u1167\u11b7"],["\uc3b1","\u110a\u1167\u11b8"],["\uc3b2","\u110a\u1167\u11b9"],["\uc3b3","\u110a\u1167\u11ba"],["\uc3b4","\u110a\u1167\u11bb"],["\uc3b5","\u110a\u1167\u11bc"],["\uc3b6","\u110a\u1167\u11bd"],["\uc3b7","\u110a\u1167\u11be"],["\uc3b8","\u110a\u1167\u11bf"],["\uc3b9","\u110a\u1167\u11c0"],["\uc3ba","\u110a\u1167\u11c1"],["\uc3bb","\u110a\u1167\u11c2"],["\uc3bc","\u110a\u1168"],["\uc3bd","\u110a\u1168\u11a8"],["\uc3be","\u110a\u1168\u11a9"],["\uc3bf","\u110a\u1168\u11aa"],["\uc3c0","\u110a\u1168\u11ab"],["\uc3c1","\u110a\u1168\u11ac"],["\uc3c2","\u110a\u1168\u11ad"],["\uc3c3","\u110a\u1168\u11ae"],["\uc3c4","\u110a\u1168\u11af"],["\uc3c5","\u110a\u1168\u11b0"],["\uc3c6","\u110a\u1168\u11b1"],["\uc3c7","\u110a\u1168\u11b2"],["\uc3c8","\u110a\u1168\u11b3"],["\uc3c9","\u110a\u1168\u11b4"],["\uc3ca","\u110a\u1168\u11b5"],["\uc3cb","\u110a\u1168\u11b6"],["\uc3cc","\u110a\u1168\u11b7"],["\uc3cd","\u110a\u1168\u11b8"],["\uc3ce","\u110a\u1168\u11b9"],["\uc3cf","\u110a\u1168\u11ba"],["\uc3d0","\u110a\u1168\u11bb"],["\uc3d1","\u110a\u1168\u11bc"],["\uc3d2","\u110a\u1168\u11bd"],["\uc3d3","\u110a\u1168\u11be"],["\uc3d4","\u110a\u1168\u11bf"],["\uc3d5","\u110a\u1168\u11c0"],["\uc3d6","\u110a\u1168\u11c1"],["\uc3d7","\u110a\u1168\u11c2"],["\uc3d8","\u110a\u1169"],["\uc3d9","\u110a\u1169\u11a8"],["\uc3da","\u110a\u1169\u11a9"],["\uc3db","\u110a\u1169\u11aa"],["\uc3dc","\u110a\u1169\u11ab"],["\uc3dd","\u110a\u1169\u11ac"],["\uc3de","\u110a\u1169\u11ad"],["\uc3df","\u110a\u1169\u11ae"],["\uc3e0","\u110a\u1169\u11af"],["\uc3e1","\u110a\u1169\u11b0"],["\uc3e2","\u110a\u1169\u11b1"],["\uc3e3","\u110a\u1169\u11b2"],["\uc3e4","\u110a\u1169\u11b3"],["\uc3e5","\u110a\u1169\u11b4"],["\uc3e6","\u110a\u1169\u11b5"],["\uc3e7","\u110a\u1169\u11b6"],["\uc3e8","\u110a\u1169\u11b7"],["\uc3e9","\u110a\u1169\u11b8"],["\uc3ea","\u110a\u1169\u11b9"],["\uc3eb","\u110a\u1169\u11ba"],["\uc3ec","\u110a\u1169\u11bb"],["\uc3ed","\u110a\u1169\u11bc"],["\uc3ee","\u110a\u1169\u11bd"],["\uc3ef","\u110a\u1169\u11be"],["\uc3f0","\u110a\u1169\u11bf"],["\uc3f1","\u110a\u1169\u11c0"],["\uc3f2","\u110a\u1169\u11c1"],["\uc3f3","\u110a\u1169\u11c2"],["\uc3f4","\u110a\u116a"],["\uc3f5","\u110a\u116a\u11a8"],["\uc3f6","\u110a\u116a\u11a9"],["\uc3f7","\u110a\u116a\u11aa"],["\uc3f8","\u110a\u116a\u11ab"],["\uc3f9","\u110a\u116a\u11ac"],["\uc3fa","\u110a\u116a\u11ad"],["\uc3fb","\u110a\u116a\u11ae"],["\uc3fc","\u110a\u116a\u11af"],["\uc3fd","\u110a\u116a\u11b0"],["\uc3fe","\u110a\u116a\u11b1"],["\uc3ff","\u110a\u116a\u11b2"],["\uc400","\u110a\u116a\u11b3"],["\uc401","\u110a\u116a\u11b4"],["\uc402","\u110a\u116a\u11b5"],["\uc403","\u110a\u116a\u11b6"],["\uc404","\u110a\u116a\u11b7"],["\uc405","\u110a\u116a\u11b8"],["\uc406","\u110a\u116a\u11b9"],["\uc407","\u110a\u116a\u11ba"],["\uc408","\u110a\u116a\u11bb"],["\uc409","\u110a\u116a\u11bc"],["\uc40a","\u110a\u116a\u11bd"],["\uc40b","\u110a\u116a\u11be"],["\uc40c","\u110a\u116a\u11bf"],["\uc40d","\u110a\u116a\u11c0"],["\uc40e","\u110a\u116a\u11c1"],["\uc40f","\u110a\u116a\u11c2"],["\uc410","\u110a\u116b"],["\uc411","\u110a\u116b\u11a8"],["\uc412","\u110a\u116b\u11a9"],["\uc413","\u110a\u116b\u11aa"],["\uc414","\u110a\u116b\u11ab"],["\uc415","\u110a\u116b\u11ac"],["\uc416","\u110a\u116b\u11ad"],["\uc417","\u110a\u116b\u11ae"],["\uc418","\u110a\u116b\u11af"],["\uc419","\u110a\u116b\u11b0"],["\uc41a","\u110a\u116b\u11b1"],["\uc41b","\u110a\u116b\u11b2"],["\uc41c","\u110a\u116b\u11b3"],["\uc41d","\u110a\u116b\u11b4"],["\uc41e","\u110a\u116b\u11b5"],["\uc41f","\u110a\u116b\u11b6"],["\uc420","\u110a\u116b\u11b7"],["\uc421","\u110a\u116b\u11b8"],["\uc422","\u110a\u116b\u11b9"],["\uc423","\u110a\u116b\u11ba"],["\uc424","\u110a\u116b\u11bb"],["\uc425","\u110a\u116b\u11bc"],["\uc426","\u110a\u116b\u11bd"],["\uc427","\u110a\u116b\u11be"],["\uc428","\u110a\u116b\u11bf"],["\uc429","\u110a\u116b\u11c0"],["\uc42a","\u110a\u116b\u11c1"],["\uc42b","\u110a\u116b\u11c2"],["\uc42c","\u110a\u116c"],["\uc42d","\u110a\u116c\u11a8"],["\uc42e","\u110a\u116c\u11a9"],["\uc42f","\u110a\u116c\u11aa"],["\uc430","\u110a\u116c\u11ab"],["\uc431","\u110a\u116c\u11ac"],["\uc432","\u110a\u116c\u11ad"],["\uc433","\u110a\u116c\u11ae"],["\uc434","\u110a\u116c\u11af"],["\uc435","\u110a\u116c\u11b0"],["\uc436","\u110a\u116c\u11b1"],["\uc437","\u110a\u116c\u11b2"],["\uc438","\u110a\u116c\u11b3"],["\uc439","\u110a\u116c\u11b4"],["\uc43a","\u110a\u116c\u11b5"],["\uc43b","\u110a\u116c\u11b6"],["\uc43c","\u110a\u116c\u11b7"],["\uc43d","\u110a\u116c\u11b8"],["\uc43e","\u110a\u116c\u11b9"],["\uc43f","\u110a\u116c\u11ba"],["\uc440","\u110a\u116c\u11bb"],["\uc441","\u110a\u116c\u11bc"],["\uc442","\u110a\u116c\u11bd"],["\uc443","\u110a\u116c\u11be"],["\uc444","\u110a\u116c\u11bf"],["\uc445","\u110a\u116c\u11c0"],["\uc446","\u110a\u116c\u11c1"],["\uc447","\u110a\u116c\u11c2"],["\uc448","\u110a\u116d"],["\uc449","\u110a\u116d\u11a8"],["\uc44a","\u110a\u116d\u11a9"],["\uc44b","\u110a\u116d\u11aa"],["\uc44c","\u110a\u116d\u11ab"],["\uc44d","\u110a\u116d\u11ac"],["\uc44e","\u110a\u116d\u11ad"],["\uc44f","\u110a\u116d\u11ae"],["\uc450","\u110a\u116d\u11af"],["\uc451","\u110a\u116d\u11b0"],["\uc452","\u110a\u116d\u11b1"],["\uc453","\u110a\u116d\u11b2"],["\uc454","\u110a\u116d\u11b3"],["\uc455","\u110a\u116d\u11b4"],["\uc456","\u110a\u116d\u11b5"],["\uc457","\u110a\u116d\u11b6"],["\uc458","\u110a\u116d\u11b7"],["\uc459","\u110a\u116d\u11b8"],["\uc45a","\u110a\u116d\u11b9"],["\uc45b","\u110a\u116d\u11ba"],["\uc45c","\u110a\u116d\u11bb"],["\uc45d","\u110a\u116d\u11bc"],["\uc45e","\u110a\u116d\u11bd"],["\uc45f","\u110a\u116d\u11be"],["\uc460","\u110a\u116d\u11bf"],["\uc461","\u110a\u116d\u11c0"],["\uc462","\u110a\u116d\u11c1"],["\uc463","\u110a\u116d\u11c2"],["\uc464","\u110a\u116e"],["\uc465","\u110a\u116e\u11a8"],["\uc466","\u110a\u116e\u11a9"],["\uc467","\u110a\u116e\u11aa"],["\uc468","\u110a\u116e\u11ab"],["\uc469","\u110a\u116e\u11ac"],["\uc46a","\u110a\u116e\u11ad"],["\uc46b","\u110a\u116e\u11ae"],["\uc46c","\u110a\u116e\u11af"],["\uc46d","\u110a\u116e\u11b0"],["\uc46e","\u110a\u116e\u11b1"],["\uc46f","\u110a\u116e\u11b2"],["\uc470","\u110a\u116e\u11b3"],["\uc471","\u110a\u116e\u11b4"],["\uc472","\u110a\u116e\u11b5"],["\uc473","\u110a\u116e\u11b6"],["\uc474","\u110a\u116e\u11b7"],["\uc475","\u110a\u116e\u11b8"],["\uc476","\u110a\u116e\u11b9"],["\uc477","\u110a\u116e\u11ba"],["\uc478","\u110a\u116e\u11bb"],["\uc479","\u110a\u116e\u11bc"],["\uc47a","\u110a\u116e\u11bd"],["\uc47b","\u110a\u116e\u11be"],["\uc47c","\u110a\u116e\u11bf"],["\uc47d","\u110a\u116e\u11c0"],["\uc47e","\u110a\u116e\u11c1"],["\uc47f","\u110a\u116e\u11c2"],["\uc480","\u110a\u116f"],["\uc481","\u110a\u116f\u11a8"],["\uc482","\u110a\u116f\u11a9"],["\uc483","\u110a\u116f\u11aa"],["\uc484","\u110a\u116f\u11ab"],["\uc485","\u110a\u116f\u11ac"],["\uc486","\u110a\u116f\u11ad"],["\uc487","\u110a\u116f\u11ae"],["\uc488","\u110a\u116f\u11af"],["\uc489","\u110a\u116f\u11b0"],["\uc48a","\u110a\u116f\u11b1"],["\uc48b","\u110a\u116f\u11b2"],["\uc48c","\u110a\u116f\u11b3"],["\uc48d","\u110a\u116f\u11b4"],["\uc48e","\u110a\u116f\u11b5"],["\uc48f","\u110a\u116f\u11b6"],["\uc490","\u110a\u116f\u11b7"],["\uc491","\u110a\u116f\u11b8"],["\uc492","\u110a\u116f\u11b9"],["\uc493","\u110a\u116f\u11ba"],["\uc494","\u110a\u116f\u11bb"],["\uc495","\u110a\u116f\u11bc"],["\uc496","\u110a\u116f\u11bd"],["\uc497","\u110a\u116f\u11be"],["\uc498","\u110a\u116f\u11bf"],["\uc499","\u110a\u116f\u11c0"],["\uc49a","\u110a\u116f\u11c1"],["\uc49b","\u110a\u116f\u11c2"],["\uc49c","\u110a\u1170"],["\uc49d","\u110a\u1170\u11a8"],["\uc49e","\u110a\u1170\u11a9"],["\uc49f","\u110a\u1170\u11aa"],["\uc4a0","\u110a\u1170\u11ab"],["\uc4a1","\u110a\u1170\u11ac"],["\uc4a2","\u110a\u1170\u11ad"],["\uc4a3","\u110a\u1170\u11ae"],["\uc4a4","\u110a\u1170\u11af"],["\uc4a5","\u110a\u1170\u11b0"],["\uc4a6","\u110a\u1170\u11b1"],["\uc4a7","\u110a\u1170\u11b2"],["\uc4a8","\u110a\u1170\u11b3"],["\uc4a9","\u110a\u1170\u11b4"],["\uc4aa","\u110a\u1170\u11b5"],["\uc4ab","\u110a\u1170\u11b6"],["\uc4ac","\u110a\u1170\u11b7"],["\uc4ad","\u110a\u1170\u11b8"],["\uc4ae","\u110a\u1170\u11b9"],["\uc4af","\u110a\u1170\u11ba"],["\uc4b0","\u110a\u1170\u11bb"],["\uc4b1","\u110a\u1170\u11bc"],["\uc4b2","\u110a\u1170\u11bd"],["\uc4b3","\u110a\u1170\u11be"],["\uc4b4","\u110a\u1170\u11bf"],["\uc4b5","\u110a\u1170\u11c0"],["\uc4b6","\u110a\u1170\u11c1"],["\uc4b7","\u110a\u1170\u11c2"],["\uc4b8","\u110a\u1171"],["\uc4b9","\u110a\u1171\u11a8"],["\uc4ba","\u110a\u1171\u11a9"],["\uc4bb","\u110a\u1171\u11aa"],["\uc4bc","\u110a\u1171\u11ab"],["\uc4bd","\u110a\u1171\u11ac"],["\uc4be","\u110a\u1171\u11ad"],["\uc4bf","\u110a\u1171\u11ae"],["\uc4c0","\u110a\u1171\u11af"],["\uc4c1","\u110a\u1171\u11b0"],["\uc4c2","\u110a\u1171\u11b1"],["\uc4c3","\u110a\u1171\u11b2"],["\uc4c4","\u110a\u1171\u11b3"],["\uc4c5","\u110a\u1171\u11b4"],["\uc4c6","\u110a\u1171\u11b5"],["\uc4c7","\u110a\u1171\u11b6"],["\uc4c8","\u110a\u1171\u11b7"],["\uc4c9","\u110a\u1171\u11b8"],["\uc4ca","\u110a\u1171\u11b9"],["\uc4cb","\u110a\u1171\u11ba"],["\uc4cc","\u110a\u1171\u11bb"],["\uc4cd","\u110a\u1171\u11bc"],["\uc4ce","\u110a\u1171\u11bd"],["\uc4cf","\u110a\u1171\u11be"],["\uc4d0","\u110a\u1171\u11bf"],["\uc4d1","\u110a\u1171\u11c0"],["\uc4d2","\u110a\u1171\u11c1"],["\uc4d3","\u110a\u1171\u11c2"],["\uc4d4","\u110a\u1172"],["\uc4d5","\u110a\u1172\u11a8"],["\uc4d6","\u110a\u1172\u11a9"],["\uc4d7","\u110a\u1172\u11aa"],["\uc4d8","\u110a\u1172\u11ab"],["\uc4d9","\u110a\u1172\u11ac"],["\uc4da","\u110a\u1172\u11ad"],["\uc4db","\u110a\u1172\u11ae"],["\uc4dc","\u110a\u1172\u11af"],["\uc4dd","\u110a\u1172\u11b0"],["\uc4de","\u110a\u1172\u11b1"],["\uc4df","\u110a\u1172\u11b2"],["\uc4e0","\u110a\u1172\u11b3"],["\uc4e1","\u110a\u1172\u11b4"],["\uc4e2","\u110a\u1172\u11b5"],["\uc4e3","\u110a\u1172\u11b6"],["\uc4e4","\u110a\u1172\u11b7"],["\uc4e5","\u110a\u1172\u11b8"],["\uc4e6","\u110a\u1172\u11b9"],["\uc4e7","\u110a\u1172\u11ba"],["\uc4e8","\u110a\u1172\u11bb"],["\uc4e9","\u110a\u1172\u11bc"],["\uc4ea","\u110a\u1172\u11bd"],["\uc4eb","\u110a\u1172\u11be"],["\uc4ec","\u110a\u1172\u11bf"],["\uc4ed","\u110a\u1172\u11c0"],["\uc4ee","\u110a\u1172\u11c1"],["\uc4ef","\u110a\u1172\u11c2"],["\uc4f0","\u110a\u1173"],["\uc4f1","\u110a\u1173\u11a8"],["\uc4f2","\u110a\u1173\u11a9"],["\uc4f3","\u110a\u1173\u11aa"],["\uc4f4","\u110a\u1173\u11ab"],["\uc4f5","\u110a\u1173\u11ac"],["\uc4f6","\u110a\u1173\u11ad"],["\uc4f7","\u110a\u1173\u11ae"],["\uc4f8","\u110a\u1173\u11af"],["\uc4f9","\u110a\u1173\u11b0"],["\uc4fa","\u110a\u1173\u11b1"],["\uc4fb","\u110a\u1173\u11b2"],["\uc4fc","\u110a\u1173\u11b3"],["\uc4fd","\u110a\u1173\u11b4"],["\uc4fe","\u110a\u1173\u11b5"],["\uc4ff","\u110a\u1173\u11b6"],["\uc500","\u110a\u1173\u11b7"],["\uc501","\u110a\u1173\u11b8"],["\uc502","\u110a\u1173\u11b9"],["\uc503","\u110a\u1173\u11ba"],["\uc504","\u110a\u1173\u11bb"],["\uc505","\u110a\u1173\u11bc"],["\uc506","\u110a\u1173\u11bd"],["\uc507","\u110a\u1173\u11be"],["\uc508","\u110a\u1173\u11bf"],["\uc509","\u110a\u1173\u11c0"],["\uc50a","\u110a\u1173\u11c1"],["\uc50b","\u110a\u1173\u11c2"],["\uc50c","\u110a\u1174"],["\uc50d","\u110a\u1174\u11a8"],["\uc50e","\u110a\u1174\u11a9"],["\uc50f","\u110a\u1174\u11aa"],["\uc510","\u110a\u1174\u11ab"],["\uc511","\u110a\u1174\u11ac"],["\uc512","\u110a\u1174\u11ad"],["\uc513","\u110a\u1174\u11ae"],["\uc514","\u110a\u1174\u11af"],["\uc515","\u110a\u1174\u11b0"],["\uc516","\u110a\u1174\u11b1"],["\uc517","\u110a\u1174\u11b2"],["\uc518","\u110a\u1174\u11b3"],["\uc519","\u110a\u1174\u11b4"],["\uc51a","\u110a\u1174\u11b5"],["\uc51b","\u110a\u1174\u11b6"],["\uc51c","\u110a\u1174\u11b7"],["\uc51d","\u110a\u1174\u11b8"],["\uc51e","\u110a\u1174\u11b9"],["\uc51f","\u110a\u1174\u11ba"],["\uc520","\u110a\u1174\u11bb"],["\uc521","\u110a\u1174\u11bc"],["\uc522","\u110a\u1174\u11bd"],["\uc523","\u110a\u1174\u11be"],["\uc524","\u110a\u1174\u11bf"],["\uc525","\u110a\u1174\u11c0"],["\uc526","\u110a\u1174\u11c1"],["\uc527","\u110a\u1174\u11c2"],["\uc528","\u110a\u1175"],["\uc529","\u110a\u1175\u11a8"],["\uc52a","\u110a\u1175\u11a9"],["\uc52b","\u110a\u1175\u11aa"],["\uc52c","\u110a\u1175\u11ab"],["\uc52d","\u110a\u1175\u11ac"],["\uc52e","\u110a\u1175\u11ad"],["\uc52f","\u110a\u1175\u11ae"],["\uc530","\u110a\u1175\u11af"],["\uc531","\u110a\u1175\u11b0"],["\uc532","\u110a\u1175\u11b1"],["\uc533","\u110a\u1175\u11b2"],["\uc534","\u110a\u1175\u11b3"],["\uc535","\u110a\u1175\u11b4"],["\uc536","\u110a\u1175\u11b5"],["\uc537","\u110a\u1175\u11b6"],["\uc538","\u110a\u1175\u11b7"],["\uc539","\u110a\u1175\u11b8"],["\uc53a","\u110a\u1175\u11b9"],["\uc53b","\u110a\u1175\u11ba"],["\uc53c","\u110a\u1175\u11bb"],["\uc53d","\u110a\u1175\u11bc"],["\uc53e","\u110a\u1175\u11bd"],["\uc53f","\u110a\u1175\u11be"],["\uc540","\u110a\u1175\u11bf"],["\uc541","\u110a\u1175\u11c0"],["\uc542","\u110a\u1175\u11c1"],["\uc543","\u110a\u1175\u11c2"],["\uc544","\u110b\u1161"],["\uc545","\u110b\u1161\u11a8"],["\uc546","\u110b\u1161\u11a9"],["\uc547","\u110b\u1161\u11aa"],["\uc548","\u110b\u1161\u11ab"],["\uc549","\u110b\u1161\u11ac"],["\uc54a","\u110b\u1161\u11ad"],["\uc54b","\u110b\u1161\u11ae"],["\uc54c","\u110b\u1161\u11af"],["\uc54d","\u110b\u1161\u11b0"],["\uc54e","\u110b\u1161\u11b1"],["\uc54f","\u110b\u1161\u11b2"],["\uc550","\u110b\u1161\u11b3"],["\uc551","\u110b\u1161\u11b4"],["\uc552","\u110b\u1161\u11b5"],["\uc553","\u110b\u1161\u11b6"],["\uc554","\u110b\u1161\u11b7"],["\uc555","\u110b\u1161\u11b8"],["\uc556","\u110b\u1161\u11b9"],["\uc557","\u110b\u1161\u11ba"],["\uc558","\u110b\u1161\u11bb"],["\uc559","\u110b\u1161\u11bc"],["\uc55a","\u110b\u1161\u11bd"],["\uc55b","\u110b\u1161\u11be"],["\uc55c","\u110b\u1161\u11bf"],["\uc55d","\u110b\u1161\u11c0"],["\uc55e","\u110b\u1161\u11c1"],["\uc55f","\u110b\u1161\u11c2"],["\uc560","\u110b\u1162"],["\uc561","\u110b\u1162\u11a8"],["\uc562","\u110b\u1162\u11a9"],["\uc563","\u110b\u1162\u11aa"],["\uc564","\u110b\u1162\u11ab"],["\uc565","\u110b\u1162\u11ac"],["\uc566","\u110b\u1162\u11ad"],["\uc567","\u110b\u1162\u11ae"],["\uc568","\u110b\u1162\u11af"],["\uc569","\u110b\u1162\u11b0"],["\uc56a","\u110b\u1162\u11b1"],["\uc56b","\u110b\u1162\u11b2"],["\uc56c","\u110b\u1162\u11b3"],["\uc56d","\u110b\u1162\u11b4"],["\uc56e","\u110b\u1162\u11b5"],["\uc56f","\u110b\u1162\u11b6"],["\uc570","\u110b\u1162\u11b7"],["\uc571","\u110b\u1162\u11b8"],["\uc572","\u110b\u1162\u11b9"],["\uc573","\u110b\u1162\u11ba"],["\uc574","\u110b\u1162\u11bb"],["\uc575","\u110b\u1162\u11bc"],["\uc576","\u110b\u1162\u11bd"],["\uc577","\u110b\u1162\u11be"],["\uc578","\u110b\u1162\u11bf"],["\uc579","\u110b\u1162\u11c0"],["\uc57a","\u110b\u1162\u11c1"],["\uc57b","\u110b\u1162\u11c2"],["\uc57c","\u110b\u1163"],["\uc57d","\u110b\u1163\u11a8"],["\uc57e","\u110b\u1163\u11a9"],["\uc57f","\u110b\u1163\u11aa"],["\uc580","\u110b\u1163\u11ab"],["\uc581","\u110b\u1163\u11ac"],["\uc582","\u110b\u1163\u11ad"],["\uc583","\u110b\u1163\u11ae"],["\uc584","\u110b\u1163\u11af"],["\uc585","\u110b\u1163\u11b0"],["\uc586","\u110b\u1163\u11b1"],["\uc587","\u110b\u1163\u11b2"],["\uc588","\u110b\u1163\u11b3"],["\uc589","\u110b\u1163\u11b4"],["\uc58a","\u110b\u1163\u11b5"],["\uc58b","\u110b\u1163\u11b6"],["\uc58c","\u110b\u1163\u11b7"],["\uc58d","\u110b\u1163\u11b8"],["\uc58e","\u110b\u1163\u11b9"],["\uc58f","\u110b\u1163\u11ba"],["\uc590","\u110b\u1163\u11bb"],["\uc591","\u110b\u1163\u11bc"],["\uc592","\u110b\u1163\u11bd"],["\uc593","\u110b\u1163\u11be"],["\uc594","\u110b\u1163\u11bf"],["\uc595","\u110b\u1163\u11c0"],["\uc596","\u110b\u1163\u11c1"],["\uc597","\u110b\u1163\u11c2"],["\uc598","\u110b\u1164"],["\uc599","\u110b\u1164\u11a8"],["\uc59a","\u110b\u1164\u11a9"],["\uc59b","\u110b\u1164\u11aa"],["\uc59c","\u110b\u1164\u11ab"],["\uc59d","\u110b\u1164\u11ac"],["\uc59e","\u110b\u1164\u11ad"],["\uc59f","\u110b\u1164\u11ae"],["\uc5a0","\u110b\u1164\u11af"],["\uc5a1","\u110b\u1164\u11b0"],["\uc5a2","\u110b\u1164\u11b1"],["\uc5a3","\u110b\u1164\u11b2"],["\uc5a4","\u110b\u1164\u11b3"],["\uc5a5","\u110b\u1164\u11b4"],["\uc5a6","\u110b\u1164\u11b5"],["\uc5a7","\u110b\u1164\u11b6"],["\uc5a8","\u110b\u1164\u11b7"],["\uc5a9","\u110b\u1164\u11b8"],["\uc5aa","\u110b\u1164\u11b9"],["\uc5ab","\u110b\u1164\u11ba"],["\uc5ac","\u110b\u1164\u11bb"],["\uc5ad","\u110b\u1164\u11bc"],["\uc5ae","\u110b\u1164\u11bd"],["\uc5af","\u110b\u1164\u11be"],["\uc5b0","\u110b\u1164\u11bf"],["\uc5b1","\u110b\u1164\u11c0"],["\uc5b2","\u110b\u1164\u11c1"],["\uc5b3","\u110b\u1164\u11c2"],["\uc5b4","\u110b\u1165"],["\uc5b5","\u110b\u1165\u11a8"],["\uc5b6","\u110b\u1165\u11a9"],["\uc5b7","\u110b\u1165\u11aa"],["\uc5b8","\u110b\u1165\u11ab"],["\uc5b9","\u110b\u1165\u11ac"],["\uc5ba","\u110b\u1165\u11ad"],["\uc5bb","\u110b\u1165\u11ae"],["\uc5bc","\u110b\u1165\u11af"],["\uc5bd","\u110b\u1165\u11b0"],["\uc5be","\u110b\u1165\u11b1"],["\uc5bf","\u110b\u1165\u11b2"],["\uc5c0","\u110b\u1165\u11b3"],["\uc5c1","\u110b\u1165\u11b4"],["\uc5c2","\u110b\u1165\u11b5"],["\uc5c3","\u110b\u1165\u11b6"],["\uc5c4","\u110b\u1165\u11b7"],["\uc5c5","\u110b\u1165\u11b8"],["\uc5c6","\u110b\u1165\u11b9"],["\uc5c7","\u110b\u1165\u11ba"],["\uc5c8","\u110b\u1165\u11bb"],["\uc5c9","\u110b\u1165\u11bc"],["\uc5ca","\u110b\u1165\u11bd"],["\uc5cb","\u110b\u1165\u11be"],["\uc5cc","\u110b\u1165\u11bf"],["\uc5cd","\u110b\u1165\u11c0"],["\uc5ce","\u110b\u1165\u11c1"],["\uc5cf","\u110b\u1165\u11c2"],["\uc5d0","\u110b\u1166"],["\uc5d1","\u110b\u1166\u11a8"],["\uc5d2","\u110b\u1166\u11a9"],["\uc5d3","\u110b\u1166\u11aa"],["\uc5d4","\u110b\u1166\u11ab"],["\uc5d5","\u110b\u1166\u11ac"],["\uc5d6","\u110b\u1166\u11ad"],["\uc5d7","\u110b\u1166\u11ae"],["\uc5d8","\u110b\u1166\u11af"],["\uc5d9","\u110b\u1166\u11b0"],["\uc5da","\u110b\u1166\u11b1"],["\uc5db","\u110b\u1166\u11b2"],["\uc5dc","\u110b\u1166\u11b3"],["\uc5dd","\u110b\u1166\u11b4"],["\uc5de","\u110b\u1166\u11b5"],["\uc5df","\u110b\u1166\u11b6"],["\uc5e0","\u110b\u1166\u11b7"],["\uc5e1","\u110b\u1166\u11b8"],["\uc5e2","\u110b\u1166\u11b9"],["\uc5e3","\u110b\u1166\u11ba"],["\uc5e4","\u110b\u1166\u11bb"],["\uc5e5","\u110b\u1166\u11bc"],["\uc5e6","\u110b\u1166\u11bd"],["\uc5e7","\u110b\u1166\u11be"],["\uc5e8","\u110b\u1166\u11bf"],["\uc5e9","\u110b\u1166\u11c0"],["\uc5ea","\u110b\u1166\u11c1"],["\uc5eb","\u110b\u1166\u11c2"],["\uc5ec","\u110b\u1167"],["\uc5ed","\u110b\u1167\u11a8"],["\uc5ee","\u110b\u1167\u11a9"],["\uc5ef","\u110b\u1167\u11aa"],["\uc5f0","\u110b\u1167\u11ab"],["\uc5f1","\u110b\u1167\u11ac"],["\uc5f2","\u110b\u1167\u11ad"],["\uc5f3","\u110b\u1167\u11ae"],["\uc5f4","\u110b\u1167\u11af"],["\uc5f5","\u110b\u1167\u11b0"],["\uc5f6","\u110b\u1167\u11b1"],["\uc5f7","\u110b\u1167\u11b2"],["\uc5f8","\u110b\u1167\u11b3"],["\uc5f9","\u110b\u1167\u11b4"],["\uc5fa","\u110b\u1167\u11b5"],["\uc5fb","\u110b\u1167\u11b6"],["\uc5fc","\u110b\u1167\u11b7"],["\uc5fd","\u110b\u1167\u11b8"],["\uc5fe","\u110b\u1167\u11b9"],["\uc5ff","\u110b\u1167\u11ba"],["\uc600","\u110b\u1167\u11bb"],["\uc601","\u110b\u1167\u11bc"],["\uc602","\u110b\u1167\u11bd"],["\uc603","\u110b\u1167\u11be"],["\uc604","\u110b\u1167\u11bf"],["\uc605","\u110b\u1167\u11c0"],["\uc606","\u110b\u1167\u11c1"],["\uc607","\u110b\u1167\u11c2"],["\uc608","\u110b\u1168"],["\uc609","\u110b\u1168\u11a8"],["\uc60a","\u110b\u1168\u11a9"],["\uc60b","\u110b\u1168\u11aa"],["\uc60c","\u110b\u1168\u11ab"],["\uc60d","\u110b\u1168\u11ac"],["\uc60e","\u110b\u1168\u11ad"],["\uc60f","\u110b\u1168\u11ae"],["\uc610","\u110b\u1168\u11af"],["\uc611","\u110b\u1168\u11b0"],["\uc612","\u110b\u1168\u11b1"],["\uc613","\u110b\u1168\u11b2"],["\uc614","\u110b\u1168\u11b3"],["\uc615","\u110b\u1168\u11b4"],["\uc616","\u110b\u1168\u11b5"],["\uc617","\u110b\u1168\u11b6"],["\uc618","\u110b\u1168\u11b7"],["\uc619","\u110b\u1168\u11b8"],["\uc61a","\u110b\u1168\u11b9"],["\uc61b","\u110b\u1168\u11ba"],["\uc61c","\u110b\u1168\u11bb"],["\uc61d","\u110b\u1168\u11bc"],["\uc61e","\u110b\u1168\u11bd"],["\uc61f","\u110b\u1168\u11be"],["\uc620","\u110b\u1168\u11bf"],["\uc621","\u110b\u1168\u11c0"],["\uc622","\u110b\u1168\u11c1"],["\uc623","\u110b\u1168\u11c2"],["\uc624","\u110b\u1169"],["\uc625","\u110b\u1169\u11a8"],["\uc626","\u110b\u1169\u11a9"],["\uc627","\u110b\u1169\u11aa"],["\uc628","\u110b\u1169\u11ab"],["\uc629","\u110b\u1169\u11ac"],["\uc62a","\u110b\u1169\u11ad"],["\uc62b","\u110b\u1169\u11ae"],["\uc62c","\u110b\u1169\u11af"],["\uc62d","\u110b\u1169\u11b0"],["\uc62e","\u110b\u1169\u11b1"],["\uc62f","\u110b\u1169\u11b2"],["\uc630","\u110b\u1169\u11b3"],["\uc631","\u110b\u1169\u11b4"],["\uc632","\u110b\u1169\u11b5"],["\uc633","\u110b\u1169\u11b6"],["\uc634","\u110b\u1169\u11b7"],["\uc635","\u110b\u1169\u11b8"],["\uc636","\u110b\u1169\u11b9"],["\uc637","\u110b\u1169\u11ba"],["\uc638","\u110b\u1169\u11bb"],["\uc639","\u110b\u1169\u11bc"],["\uc63a","\u110b\u1169\u11bd"],["\uc63b","\u110b\u1169\u11be"],["\uc63c","\u110b\u1169\u11bf"],["\uc63d","\u110b\u1169\u11c0"],["\uc63e","\u110b\u1169\u11c1"],["\uc63f","\u110b\u1169\u11c2"],["\uc640","\u110b\u116a"],["\uc641","\u110b\u116a\u11a8"],["\uc642","\u110b\u116a\u11a9"],["\uc643","\u110b\u116a\u11aa"],["\uc644","\u110b\u116a\u11ab"],["\uc645","\u110b\u116a\u11ac"],["\uc646","\u110b\u116a\u11ad"],["\uc647","\u110b\u116a\u11ae"],["\uc648","\u110b\u116a\u11af"],["\uc649","\u110b\u116a\u11b0"],["\uc64a","\u110b\u116a\u11b1"],["\uc64b","\u110b\u116a\u11b2"],["\uc64c","\u110b\u116a\u11b3"],["\uc64d","\u110b\u116a\u11b4"],["\uc64e","\u110b\u116a\u11b5"],["\uc64f","\u110b\u116a\u11b6"],["\uc650","\u110b\u116a\u11b7"],["\uc651","\u110b\u116a\u11b8"],["\uc652","\u110b\u116a\u11b9"],["\uc653","\u110b\u116a\u11ba"],["\uc654","\u110b\u116a\u11bb"],["\uc655","\u110b\u116a\u11bc"],["\uc656","\u110b\u116a\u11bd"],["\uc657","\u110b\u116a\u11be"],["\uc658","\u110b\u116a\u11bf"],["\uc659","\u110b\u116a\u11c0"],["\uc65a","\u110b\u116a\u11c1"],["\uc65b","\u110b\u116a\u11c2"],["\uc65c","\u110b\u116b"],["\uc65d","\u110b\u116b\u11a8"],["\uc65e","\u110b\u116b\u11a9"],["\uc65f","\u110b\u116b\u11aa"],["\uc660","\u110b\u116b\u11ab"],["\uc661","\u110b\u116b\u11ac"],["\uc662","\u110b\u116b\u11ad"],["\uc663","\u110b\u116b\u11ae"],["\uc664","\u110b\u116b\u11af"],["\uc665","\u110b\u116b\u11b0"],["\uc666","\u110b\u116b\u11b1"],["\uc667","\u110b\u116b\u11b2"],["\uc668","\u110b\u116b\u11b3"],["\uc669","\u110b\u116b\u11b4"],["\uc66a","\u110b\u116b\u11b5"],["\uc66b","\u110b\u116b\u11b6"],["\uc66c","\u110b\u116b\u11b7"],["\uc66d","\u110b\u116b\u11b8"],["\uc66e","\u110b\u116b\u11b9"],["\uc66f","\u110b\u116b\u11ba"],["\uc670","\u110b\u116b\u11bb"],["\uc671","\u110b\u116b\u11bc"],["\uc672","\u110b\u116b\u11bd"],["\uc673","\u110b\u116b\u11be"],["\uc674","\u110b\u116b\u11bf"],["\uc675","\u110b\u116b\u11c0"],["\uc676","\u110b\u116b\u11c1"],["\uc677","\u110b\u116b\u11c2"],["\uc678","\u110b\u116c"],["\uc679","\u110b\u116c\u11a8"],["\uc67a","\u110b\u116c\u11a9"],["\uc67b","\u110b\u116c\u11aa"],["\uc67c","\u110b\u116c\u11ab"],["\uc67d","\u110b\u116c\u11ac"],["\uc67e","\u110b\u116c\u11ad"],["\uc67f","\u110b\u116c\u11ae"],["\uc680","\u110b\u116c\u11af"],["\uc681","\u110b\u116c\u11b0"],["\uc682","\u110b\u116c\u11b1"],["\uc683","\u110b\u116c\u11b2"],["\uc684","\u110b\u116c\u11b3"],["\uc685","\u110b\u116c\u11b4"],["\uc686","\u110b\u116c\u11b5"],["\uc687","\u110b\u116c\u11b6"],["\uc688","\u110b\u116c\u11b7"],["\uc689","\u110b\u116c\u11b8"],["\uc68a","\u110b\u116c\u11b9"],["\uc68b","\u110b\u116c\u11ba"],["\uc68c","\u110b\u116c\u11bb"],["\uc68d","\u110b\u116c\u11bc"],["\uc68e","\u110b\u116c\u11bd"],["\uc68f","\u110b\u116c\u11be"],["\uc690","\u110b\u116c\u11bf"],["\uc691","\u110b\u116c\u11c0"],["\uc692","\u110b\u116c\u11c1"],["\uc693","\u110b\u116c\u11c2"],["\uc694","\u110b\u116d"],["\uc695","\u110b\u116d\u11a8"],["\uc696","\u110b\u116d\u11a9"],["\uc697","\u110b\u116d\u11aa"],["\uc698","\u110b\u116d\u11ab"],["\uc699","\u110b\u116d\u11ac"],["\uc69a","\u110b\u116d\u11ad"],["\uc69b","\u110b\u116d\u11ae"],["\uc69c","\u110b\u116d\u11af"],["\uc69d","\u110b\u116d\u11b0"],["\uc69e","\u110b\u116d\u11b1"],["\uc69f","\u110b\u116d\u11b2"],["\uc6a0","\u110b\u116d\u11b3"],["\uc6a1","\u110b\u116d\u11b4"],["\uc6a2","\u110b\u116d\u11b5"],["\uc6a3","\u110b\u116d\u11b6"],["\uc6a4","\u110b\u116d\u11b7"],["\uc6a5","\u110b\u116d\u11b8"],["\uc6a6","\u110b\u116d\u11b9"],["\uc6a7","\u110b\u116d\u11ba"],["\uc6a8","\u110b\u116d\u11bb"],["\uc6a9","\u110b\u116d\u11bc"],["\uc6aa","\u110b\u116d\u11bd"],["\uc6ab","\u110b\u116d\u11be"],["\uc6ac","\u110b\u116d\u11bf"],["\uc6ad","\u110b\u116d\u11c0"],["\uc6ae","\u110b\u116d\u11c1"],["\uc6af","\u110b\u116d\u11c2"],["\uc6b0","\u110b\u116e"],["\uc6b1","\u110b\u116e\u11a8"],["\uc6b2","\u110b\u116e\u11a9"],["\uc6b3","\u110b\u116e\u11aa"],["\uc6b4","\u110b\u116e\u11ab"],["\uc6b5","\u110b\u116e\u11ac"],["\uc6b6","\u110b\u116e\u11ad"],["\uc6b7","\u110b\u116e\u11ae"],["\uc6b8","\u110b\u116e\u11af"],["\uc6b9","\u110b\u116e\u11b0"],["\uc6ba","\u110b\u116e\u11b1"],["\uc6bb","\u110b\u116e\u11b2"],["\uc6bc","\u110b\u116e\u11b3"],["\uc6bd","\u110b\u116e\u11b4"],["\uc6be","\u110b\u116e\u11b5"],["\uc6bf","\u110b\u116e\u11b6"],["\uc6c0","\u110b\u116e\u11b7"],["\uc6c1","\u110b\u116e\u11b8"],["\uc6c2","\u110b\u116e\u11b9"],["\uc6c3","\u110b\u116e\u11ba"],["\uc6c4","\u110b\u116e\u11bb"],["\uc6c5","\u110b\u116e\u11bc"],["\uc6c6","\u110b\u116e\u11bd"],["\uc6c7","\u110b\u116e\u11be"],["\uc6c8","\u110b\u116e\u11bf"],["\uc6c9","\u110b\u116e\u11c0"],["\uc6ca","\u110b\u116e\u11c1"],["\uc6cb","\u110b\u116e\u11c2"],["\uc6cc","\u110b\u116f"],["\uc6cd","\u110b\u116f\u11a8"],["\uc6ce","\u110b\u116f\u11a9"],["\uc6cf","\u110b\u116f\u11aa"],["\uc6d0","\u110b\u116f\u11ab"],["\uc6d1","\u110b\u116f\u11ac"],["\uc6d2","\u110b\u116f\u11ad"],["\uc6d3","\u110b\u116f\u11ae"],["\uc6d4","\u110b\u116f\u11af"],["\uc6d5","\u110b\u116f\u11b0"],["\uc6d6","\u110b\u116f\u11b1"],["\uc6d7","\u110b\u116f\u11b2"],["\uc6d8","\u110b\u116f\u11b3"],["\uc6d9","\u110b\u116f\u11b4"],["\uc6da","\u110b\u116f\u11b5"],["\uc6db","\u110b\u116f\u11b6"],["\uc6dc","\u110b\u116f\u11b7"],["\uc6dd","\u110b\u116f\u11b8"],["\uc6de","\u110b\u116f\u11b9"],["\uc6df","\u110b\u116f\u11ba"],["\uc6e0","\u110b\u116f\u11bb"],["\uc6e1","\u110b\u116f\u11bc"],["\uc6e2","\u110b\u116f\u11bd"],["\uc6e3","\u110b\u116f\u11be"],["\uc6e4","\u110b\u116f\u11bf"],["\uc6e5","\u110b\u116f\u11c0"],["\uc6e6","\u110b\u116f\u11c1"],["\uc6e7","\u110b\u116f\u11c2"],["\uc6e8","\u110b\u1170"],["\uc6e9","\u110b\u1170\u11a8"],["\uc6ea","\u110b\u1170\u11a9"],["\uc6eb","\u110b\u1170\u11aa"],["\uc6ec","\u110b\u1170\u11ab"],["\uc6ed","\u110b\u1170\u11ac"],["\uc6ee","\u110b\u1170\u11ad"],["\uc6ef","\u110b\u1170\u11ae"],["\uc6f0","\u110b\u1170\u11af"],["\uc6f1","\u110b\u1170\u11b0"],["\uc6f2","\u110b\u1170\u11b1"],["\uc6f3","\u110b\u1170\u11b2"],["\uc6f4","\u110b\u1170\u11b3"],["\uc6f5","\u110b\u1170\u11b4"],["\uc6f6","\u110b\u1170\u11b5"],["\uc6f7","\u110b\u1170\u11b6"],["\uc6f8","\u110b\u1170\u11b7"],["\uc6f9","\u110b\u1170\u11b8"],["\uc6fa","\u110b\u1170\u11b9"],["\uc6fb","\u110b\u1170\u11ba"],["\uc6fc","\u110b\u1170\u11bb"],["\uc6fd","\u110b\u1170\u11bc"],["\uc6fe","\u110b\u1170\u11bd"],["\uc6ff","\u110b\u1170\u11be"],["\uc700","\u110b\u1170\u11bf"],["\uc701","\u110b\u1170\u11c0"],["\uc702","\u110b\u1170\u11c1"],["\uc703","\u110b\u1170\u11c2"],["\uc704","\u110b\u1171"],["\uc705","\u110b\u1171\u11a8"],["\uc706","\u110b\u1171\u11a9"],["\uc707","\u110b\u1171\u11aa"],["\uc708","\u110b\u1171\u11ab"],["\uc709","\u110b\u1171\u11ac"],["\uc70a","\u110b\u1171\u11ad"],["\uc70b","\u110b\u1171\u11ae"],["\uc70c","\u110b\u1171\u11af"],["\uc70d","\u110b\u1171\u11b0"],["\uc70e","\u110b\u1171\u11b1"],["\uc70f","\u110b\u1171\u11b2"],["\uc710","\u110b\u1171\u11b3"],["\uc711","\u110b\u1171\u11b4"],["\uc712","\u110b\u1171\u11b5"],["\uc713","\u110b\u1171\u11b6"],["\uc714","\u110b\u1171\u11b7"],["\uc715","\u110b\u1171\u11b8"],["\uc716","\u110b\u1171\u11b9"],["\uc717","\u110b\u1171\u11ba"],["\uc718","\u110b\u1171\u11bb"],["\uc719","\u110b\u1171\u11bc"],["\uc71a","\u110b\u1171\u11bd"],["\uc71b","\u110b\u1171\u11be"],["\uc71c","\u110b\u1171\u11bf"],["\uc71d","\u110b\u1171\u11c0"],["\uc71e","\u110b\u1171\u11c1"],["\uc71f","\u110b\u1171\u11c2"],["\uc720","\u110b\u1172"],["\uc721","\u110b\u1172\u11a8"],["\uc722","\u110b\u1172\u11a9"],["\uc723","\u110b\u1172\u11aa"],["\uc724","\u110b\u1172\u11ab"],["\uc725","\u110b\u1172\u11ac"],["\uc726","\u110b\u1172\u11ad"],["\uc727","\u110b\u1172\u11ae"],["\uc728","\u110b\u1172\u11af"],["\uc729","\u110b\u1172\u11b0"],["\uc72a","\u110b\u1172\u11b1"],["\uc72b","\u110b\u1172\u11b2"],["\uc72c","\u110b\u1172\u11b3"],["\uc72d","\u110b\u1172\u11b4"],["\uc72e","\u110b\u1172\u11b5"],["\uc72f","\u110b\u1172\u11b6"],["\uc730","\u110b\u1172\u11b7"],["\uc731","\u110b\u1172\u11b8"],["\uc732","\u110b\u1172\u11b9"],["\uc733","\u110b\u1172\u11ba"],["\uc734","\u110b\u1172\u11bb"],["\uc735","\u110b\u1172\u11bc"],["\uc736","\u110b\u1172\u11bd"],["\uc737","\u110b\u1172\u11be"],["\uc738","\u110b\u1172\u11bf"],["\uc739","\u110b\u1172\u11c0"],["\uc73a","\u110b\u1172\u11c1"],["\uc73b","\u110b\u1172\u11c2"],["\uc73c","\u110b\u1173"],["\uc73d","\u110b\u1173\u11a8"],["\uc73e","\u110b\u1173\u11a9"],["\uc73f","\u110b\u1173\u11aa"],["\uc740","\u110b\u1173\u11ab"],["\uc741","\u110b\u1173\u11ac"],["\uc742","\u110b\u1173\u11ad"],["\uc743","\u110b\u1173\u11ae"],["\uc744","\u110b\u1173\u11af"],["\uc745","\u110b\u1173\u11b0"],["\uc746","\u110b\u1173\u11b1"],["\uc747","\u110b\u1173\u11b2"],["\uc748","\u110b\u1173\u11b3"],["\uc749","\u110b\u1173\u11b4"],["\uc74a","\u110b\u1173\u11b5"],["\uc74b","\u110b\u1173\u11b6"],["\uc74c","\u110b\u1173\u11b7"],["\uc74d","\u110b\u1173\u11b8"],["\uc74e","\u110b\u1173\u11b9"],["\uc74f","\u110b\u1173\u11ba"],["\uc750","\u110b\u1173\u11bb"],["\uc751","\u110b\u1173\u11bc"],["\uc752","\u110b\u1173\u11bd"],["\uc753","\u110b\u1173\u11be"],["\uc754","\u110b\u1173\u11bf"],["\uc755","\u110b\u1173\u11c0"],["\uc756","\u110b\u1173\u11c1"],["\uc757","\u110b\u1173\u11c2"],["\uc758","\u110b\u1174"],["\uc759","\u110b\u1174\u11a8"],["\uc75a","\u110b\u1174\u11a9"],["\uc75b","\u110b\u1174\u11aa"],["\uc75c","\u110b\u1174\u11ab"],["\uc75d","\u110b\u1174\u11ac"],["\uc75e","\u110b\u1174\u11ad"],["\uc75f","\u110b\u1174\u11ae"],["\uc760","\u110b\u1174\u11af"],["\uc761","\u110b\u1174\u11b0"],["\uc762","\u110b\u1174\u11b1"],["\uc763","\u110b\u1174\u11b2"],["\uc764","\u110b\u1174\u11b3"],["\uc765","\u110b\u1174\u11b4"],["\uc766","\u110b\u1174\u11b5"],["\uc767","\u110b\u1174\u11b6"],["\uc768","\u110b\u1174\u11b7"],["\uc769","\u110b\u1174\u11b8"],["\uc76a","\u110b\u1174\u11b9"],["\uc76b","\u110b\u1174\u11ba"],["\uc76c","\u110b\u1174\u11bb"],["\uc76d","\u110b\u1174\u11bc"],["\uc76e","\u110b\u1174\u11bd"],["\uc76f","\u110b\u1174\u11be"],["\uc770","\u110b\u1174\u11bf"],["\uc771","\u110b\u1174\u11c0"],["\uc772","\u110b\u1174\u11c1"],["\uc773","\u110b\u1174\u11c2"],["\uc774","\u110b\u1175"],["\uc775","\u110b\u1175\u11a8"],["\uc776","\u110b\u1175\u11a9"],["\uc777","\u110b\u1175\u11aa"],["\uc778","\u110b\u1175\u11ab"],["\uc779","\u110b\u1175\u11ac"],["\uc77a","\u110b\u1175\u11ad"],["\uc77b","\u110b\u1175\u11ae"],["\uc77c","\u110b\u1175\u11af"],["\uc77d","\u110b\u1175\u11b0"],["\uc77e","\u110b\u1175\u11b1"],["\uc77f","\u110b\u1175\u11b2"],["\uc780","\u110b\u1175\u11b3"],["\uc781","\u110b\u1175\u11b4"],["\uc782","\u110b\u1175\u11b5"],["\uc783","\u110b\u1175\u11b6"],["\uc784","\u110b\u1175\u11b7"],["\uc785","\u110b\u1175\u11b8"],["\uc786","\u110b\u1175\u11b9"],["\uc787","\u110b\u1175\u11ba"],["\uc788","\u110b\u1175\u11bb"],["\uc789","\u110b\u1175\u11bc"],["\uc78a","\u110b\u1175\u11bd"],["\uc78b","\u110b\u1175\u11be"],["\uc78c","\u110b\u1175\u11bf"],["\uc78d","\u110b\u1175\u11c0"],["\uc78e","\u110b\u1175\u11c1"],["\uc78f","\u110b\u1175\u11c2"],["\uc790","\u110c\u1161"],["\uc791","\u110c\u1161\u11a8"],["\uc792","\u110c\u1161\u11a9"],["\uc793","\u110c\u1161\u11aa"],["\uc794","\u110c\u1161\u11ab"],["\uc795","\u110c\u1161\u11ac"],["\uc796","\u110c\u1161\u11ad"],["\uc797","\u110c\u1161\u11ae"],["\uc798","\u110c\u1161\u11af"],["\uc799","\u110c\u1161\u11b0"],["\uc79a","\u110c\u1161\u11b1"],["\uc79b","\u110c\u1161\u11b2"],["\uc79c","\u110c\u1161\u11b3"],["\uc79d","\u110c\u1161\u11b4"],["\uc79e","\u110c\u1161\u11b5"],["\uc79f","\u110c\u1161\u11b6"],["\uc7a0","\u110c\u1161\u11b7"],["\uc7a1","\u110c\u1161\u11b8"],["\uc7a2","\u110c\u1161\u11b9"],["\uc7a3","\u110c\u1161\u11ba"],["\uc7a4","\u110c\u1161\u11bb"],["\uc7a5","\u110c\u1161\u11bc"],["\uc7a6","\u110c\u1161\u11bd"],["\uc7a7","\u110c\u1161\u11be"],["\uc7a8","\u110c\u1161\u11bf"],["\uc7a9","\u110c\u1161\u11c0"],["\uc7aa","\u110c\u1161\u11c1"],["\uc7ab","\u110c\u1161\u11c2"],["\uc7ac","\u110c\u1162"],["\uc7ad","\u110c\u1162\u11a8"],["\uc7ae","\u110c\u1162\u11a9"],["\uc7af","\u110c\u1162\u11aa"],["\uc7b0","\u110c\u1162\u11ab"],["\uc7b1","\u110c\u1162\u11ac"],["\uc7b2","\u110c\u1162\u11ad"],["\uc7b3","\u110c\u1162\u11ae"],["\uc7b4","\u110c\u1162\u11af"],["\uc7b5","\u110c\u1162\u11b0"],["\uc7b6","\u110c\u1162\u11b1"],["\uc7b7","\u110c\u1162\u11b2"],["\uc7b8","\u110c\u1162\u11b3"],["\uc7b9","\u110c\u1162\u11b4"],["\uc7ba","\u110c\u1162\u11b5"],["\uc7bb","\u110c\u1162\u11b6"],["\uc7bc","\u110c\u1162\u11b7"],["\uc7bd","\u110c\u1162\u11b8"],["\uc7be","\u110c\u1162\u11b9"],["\uc7bf","\u110c\u1162\u11ba"],["\uc7c0","\u110c\u1162\u11bb"],["\uc7c1","\u110c\u1162\u11bc"],["\uc7c2","\u110c\u1162\u11bd"],["\uc7c3","\u110c\u1162\u11be"],["\uc7c4","\u110c\u1162\u11bf"],["\uc7c5","\u110c\u1162\u11c0"],["\uc7c6","\u110c\u1162\u11c1"],["\uc7c7","\u110c\u1162\u11c2"],["\uc7c8","\u110c\u1163"],["\uc7c9","\u110c\u1163\u11a8"],["\uc7ca","\u110c\u1163\u11a9"],["\uc7cb","\u110c\u1163\u11aa"],["\uc7cc","\u110c\u1163\u11ab"],["\uc7cd","\u110c\u1163\u11ac"],["\uc7ce","\u110c\u1163\u11ad"],["\uc7cf","\u110c\u1163\u11ae"],["\uc7d0","\u110c\u1163\u11af"],["\uc7d1","\u110c\u1163\u11b0"],["\uc7d2","\u110c\u1163\u11b1"],["\uc7d3","\u110c\u1163\u11b2"],["\uc7d4","\u110c\u1163\u11b3"],["\uc7d5","\u110c\u1163\u11b4"],["\uc7d6","\u110c\u1163\u11b5"],["\uc7d7","\u110c\u1163\u11b6"],["\uc7d8","\u110c\u1163\u11b7"],["\uc7d9","\u110c\u1163\u11b8"],["\uc7da","\u110c\u1163\u11b9"],["\uc7db","\u110c\u1163\u11ba"],["\uc7dc","\u110c\u1163\u11bb"],["\uc7dd","\u110c\u1163\u11bc"],["\uc7de","\u110c\u1163\u11bd"],["\uc7df","\u110c\u1163\u11be"],["\uc7e0","\u110c\u1163\u11bf"],["\uc7e1","\u110c\u1163\u11c0"],["\uc7e2","\u110c\u1163\u11c1"],["\uc7e3","\u110c\u1163\u11c2"],["\uc7e4","\u110c\u1164"],["\uc7e5","\u110c\u1164\u11a8"],["\uc7e6","\u110c\u1164\u11a9"],["\uc7e7","\u110c\u1164\u11aa"],["\uc7e8","\u110c\u1164\u11ab"],["\uc7e9","\u110c\u1164\u11ac"],["\uc7ea","\u110c\u1164\u11ad"],["\uc7eb","\u110c\u1164\u11ae"],["\uc7ec","\u110c\u1164\u11af"],["\uc7ed","\u110c\u1164\u11b0"],["\uc7ee","\u110c\u1164\u11b1"],["\uc7ef","\u110c\u1164\u11b2"],["\uc7f0","\u110c\u1164\u11b3"],["\uc7f1","\u110c\u1164\u11b4"],["\uc7f2","\u110c\u1164\u11b5"],["\uc7f3","\u110c\u1164\u11b6"],["\uc7f4","\u110c\u1164\u11b7"],["\uc7f5","\u110c\u1164\u11b8"],["\uc7f6","\u110c\u1164\u11b9"],["\uc7f7","\u110c\u1164\u11ba"],["\uc7f8","\u110c\u1164\u11bb"],["\uc7f9","\u110c\u1164\u11bc"],["\uc7fa","\u110c\u1164\u11bd"],["\uc7fb","\u110c\u1164\u11be"],["\uc7fc","\u110c\u1164\u11bf"],["\uc7fd","\u110c\u1164\u11c0"],["\uc7fe","\u110c\u1164\u11c1"],["\uc7ff","\u110c\u1164\u11c2"],["\uc800","\u110c\u1165"],["\uc801","\u110c\u1165\u11a8"],["\uc802","\u110c\u1165\u11a9"],["\uc803","\u110c\u1165\u11aa"],["\uc804","\u110c\u1165\u11ab"],["\uc805","\u110c\u1165\u11ac"],["\uc806","\u110c\u1165\u11ad"],["\uc807","\u110c\u1165\u11ae"],["\uc808","\u110c\u1165\u11af"],["\uc809","\u110c\u1165\u11b0"],["\uc80a","\u110c\u1165\u11b1"],["\uc80b","\u110c\u1165\u11b2"],["\uc80c","\u110c\u1165\u11b3"],["\uc80d","\u110c\u1165\u11b4"],["\uc80e","\u110c\u1165\u11b5"],["\uc80f","\u110c\u1165\u11b6"],["\uc810","\u110c\u1165\u11b7"],["\uc811","\u110c\u1165\u11b8"],["\uc812","\u110c\u1165\u11b9"],["\uc813","\u110c\u1165\u11ba"],["\uc814","\u110c\u1165\u11bb"],["\uc815","\u110c\u1165\u11bc"],["\uc816","\u110c\u1165\u11bd"],["\uc817","\u110c\u1165\u11be"],["\uc818","\u110c\u1165\u11bf"],["\uc819","\u110c\u1165\u11c0"],["\uc81a","\u110c\u1165\u11c1"],["\uc81b","\u110c\u1165\u11c2"],["\uc81c","\u110c\u1166"],["\uc81d","\u110c\u1166\u11a8"],["\uc81e","\u110c\u1166\u11a9"],["\uc81f","\u110c\u1166\u11aa"],["\uc820","\u110c\u1166\u11ab"],["\uc821","\u110c\u1166\u11ac"],["\uc822","\u110c\u1166\u11ad"],["\uc823","\u110c\u1166\u11ae"],["\uc824","\u110c\u1166\u11af"],["\uc825","\u110c\u1166\u11b0"],["\uc826","\u110c\u1166\u11b1"],["\uc827","\u110c\u1166\u11b2"],["\uc828","\u110c\u1166\u11b3"],["\uc829","\u110c\u1166\u11b4"],["\uc82a","\u110c\u1166\u11b5"],["\uc82b","\u110c\u1166\u11b6"],["\uc82c","\u110c\u1166\u11b7"],["\uc82d","\u110c\u1166\u11b8"],["\uc82e","\u110c\u1166\u11b9"],["\uc82f","\u110c\u1166\u11ba"],["\uc830","\u110c\u1166\u11bb"],["\uc831","\u110c\u1166\u11bc"],["\uc832","\u110c\u1166\u11bd"],["\uc833","\u110c\u1166\u11be"],["\uc834","\u110c\u1166\u11bf"],["\uc835","\u110c\u1166\u11c0"],["\uc836","\u110c\u1166\u11c1"],["\uc837","\u110c\u1166\u11c2"],["\uc838","\u110c\u1167"],["\uc839","\u110c\u1167\u11a8"],["\uc83a","\u110c\u1167\u11a9"],["\uc83b","\u110c\u1167\u11aa"],["\uc83c","\u110c\u1167\u11ab"],["\uc83d","\u110c\u1167\u11ac"],["\uc83e","\u110c\u1167\u11ad"],["\uc83f","\u110c\u1167\u11ae"],["\uc840","\u110c\u1167\u11af"],["\uc841","\u110c\u1167\u11b0"],["\uc842","\u110c\u1167\u11b1"],["\uc843","\u110c\u1167\u11b2"],["\uc844","\u110c\u1167\u11b3"],["\uc845","\u110c\u1167\u11b4"],["\uc846","\u110c\u1167\u11b5"],["\uc847","\u110c\u1167\u11b6"],["\uc848","\u110c\u1167\u11b7"],["\uc849","\u110c\u1167\u11b8"],["\uc84a","\u110c\u1167\u11b9"],["\uc84b","\u110c\u1167\u11ba"],["\uc84c","\u110c\u1167\u11bb"],["\uc84d","\u110c\u1167\u11bc"],["\uc84e","\u110c\u1167\u11bd"],["\uc84f","\u110c\u1167\u11be"],["\uc850","\u110c\u1167\u11bf"],["\uc851","\u110c\u1167\u11c0"],["\uc852","\u110c\u1167\u11c1"],["\uc853","\u110c\u1167\u11c2"],["\uc854","\u110c\u1168"],["\uc855","\u110c\u1168\u11a8"],["\uc856","\u110c\u1168\u11a9"],["\uc857","\u110c\u1168\u11aa"],["\uc858","\u110c\u1168\u11ab"],["\uc859","\u110c\u1168\u11ac"],["\uc85a","\u110c\u1168\u11ad"],["\uc85b","\u110c\u1168\u11ae"],["\uc85c","\u110c\u1168\u11af"],["\uc85d","\u110c\u1168\u11b0"],["\uc85e","\u110c\u1168\u11b1"],["\uc85f","\u110c\u1168\u11b2"],["\uc860","\u110c\u1168\u11b3"],["\uc861","\u110c\u1168\u11b4"],["\uc862","\u110c\u1168\u11b5"],["\uc863","\u110c\u1168\u11b6"],["\uc864","\u110c\u1168\u11b7"],["\uc865","\u110c\u1168\u11b8"],["\uc866","\u110c\u1168\u11b9"],["\uc867","\u110c\u1168\u11ba"],["\uc868","\u110c\u1168\u11bb"],["\uc869","\u110c\u1168\u11bc"],["\uc86a","\u110c\u1168\u11bd"],["\uc86b","\u110c\u1168\u11be"],["\uc86c","\u110c\u1168\u11bf"],["\uc86d","\u110c\u1168\u11c0"],["\uc86e","\u110c\u1168\u11c1"],["\uc86f","\u110c\u1168\u11c2"],["\uc870","\u110c\u1169"],["\uc871","\u110c\u1169\u11a8"],["\uc872","\u110c\u1169\u11a9"],["\uc873","\u110c\u1169\u11aa"],["\uc874","\u110c\u1169\u11ab"],["\uc875","\u110c\u1169\u11ac"],["\uc876","\u110c\u1169\u11ad"],["\uc877","\u110c\u1169\u11ae"],["\uc878","\u110c\u1169\u11af"],["\uc879","\u110c\u1169\u11b0"],["\uc87a","\u110c\u1169\u11b1"],["\uc87b","\u110c\u1169\u11b2"],["\uc87c","\u110c\u1169\u11b3"],["\uc87d","\u110c\u1169\u11b4"],["\uc87e","\u110c\u1169\u11b5"],["\uc87f","\u110c\u1169\u11b6"],["\uc880","\u110c\u1169\u11b7"],["\uc881","\u110c\u1169\u11b8"],["\uc882","\u110c\u1169\u11b9"],["\uc883","\u110c\u1169\u11ba"],["\uc884","\u110c\u1169\u11bb"],["\uc885","\u110c\u1169\u11bc"],["\uc886","\u110c\u1169\u11bd"],["\uc887","\u110c\u1169\u11be"],["\uc888","\u110c\u1169\u11bf"],["\uc889","\u110c\u1169\u11c0"],["\uc88a","\u110c\u1169\u11c1"],["\uc88b","\u110c\u1169\u11c2"],["\uc88c","\u110c\u116a"],["\uc88d","\u110c\u116a\u11a8"],["\uc88e","\u110c\u116a\u11a9"],["\uc88f","\u110c\u116a\u11aa"],["\uc890","\u110c\u116a\u11ab"],["\uc891","\u110c\u116a\u11ac"],["\uc892","\u110c\u116a\u11ad"],["\uc893","\u110c\u116a\u11ae"],["\uc894","\u110c\u116a\u11af"],["\uc895","\u110c\u116a\u11b0"],["\uc896","\u110c\u116a\u11b1"],["\uc897","\u110c\u116a\u11b2"],["\uc898","\u110c\u116a\u11b3"],["\uc899","\u110c\u116a\u11b4"],["\uc89a","\u110c\u116a\u11b5"],["\uc89b","\u110c\u116a\u11b6"],["\uc89c","\u110c\u116a\u11b7"],["\uc89d","\u110c\u116a\u11b8"],["\uc89e","\u110c\u116a\u11b9"],["\uc89f","\u110c\u116a\u11ba"],["\uc8a0","\u110c\u116a\u11bb"],["\uc8a1","\u110c\u116a\u11bc"],["\uc8a2","\u110c\u116a\u11bd"],["\uc8a3","\u110c\u116a\u11be"],["\uc8a4","\u110c\u116a\u11bf"],["\uc8a5","\u110c\u116a\u11c0"],["\uc8a6","\u110c\u116a\u11c1"],["\uc8a7","\u110c\u116a\u11c2"],["\uc8a8","\u110c\u116b"],["\uc8a9","\u110c\u116b\u11a8"],["\uc8aa","\u110c\u116b\u11a9"],["\uc8ab","\u110c\u116b\u11aa"],["\uc8ac","\u110c\u116b\u11ab"],["\uc8ad","\u110c\u116b\u11ac"],["\uc8ae","\u110c\u116b\u11ad"],["\uc8af","\u110c\u116b\u11ae"],["\uc8b0","\u110c\u116b\u11af"],["\uc8b1","\u110c\u116b\u11b0"],["\uc8b2","\u110c\u116b\u11b1"],["\uc8b3","\u110c\u116b\u11b2"],["\uc8b4","\u110c\u116b\u11b3"],["\uc8b5","\u110c\u116b\u11b4"],["\uc8b6","\u110c\u116b\u11b5"],["\uc8b7","\u110c\u116b\u11b6"],["\uc8b8","\u110c\u116b\u11b7"],["\uc8b9","\u110c\u116b\u11b8"],["\uc8ba","\u110c\u116b\u11b9"],["\uc8bb","\u110c\u116b\u11ba"],["\uc8bc","\u110c\u116b\u11bb"],["\uc8bd","\u110c\u116b\u11bc"],["\uc8be","\u110c\u116b\u11bd"],["\uc8bf","\u110c\u116b\u11be"],["\uc8c0","\u110c\u116b\u11bf"],["\uc8c1","\u110c\u116b\u11c0"],["\uc8c2","\u110c\u116b\u11c1"],["\uc8c3","\u110c\u116b\u11c2"],["\uc8c4","\u110c\u116c"],["\uc8c5","\u110c\u116c\u11a8"],["\uc8c6","\u110c\u116c\u11a9"],["\uc8c7","\u110c\u116c\u11aa"],["\uc8c8","\u110c\u116c\u11ab"],["\uc8c9","\u110c\u116c\u11ac"],["\uc8ca","\u110c\u116c\u11ad"],["\uc8cb","\u110c\u116c\u11ae"],["\uc8cc","\u110c\u116c\u11af"],["\uc8cd","\u110c\u116c\u11b0"],["\uc8ce","\u110c\u116c\u11b1"],["\uc8cf","\u110c\u116c\u11b2"],["\uc8d0","\u110c\u116c\u11b3"],["\uc8d1","\u110c\u116c\u11b4"],["\uc8d2","\u110c\u116c\u11b5"],["\uc8d3","\u110c\u116c\u11b6"],["\uc8d4","\u110c\u116c\u11b7"],["\uc8d5","\u110c\u116c\u11b8"],["\uc8d6","\u110c\u116c\u11b9"],["\uc8d7","\u110c\u116c\u11ba"],["\uc8d8","\u110c\u116c\u11bb"],["\uc8d9","\u110c\u116c\u11bc"],["\uc8da","\u110c\u116c\u11bd"],["\uc8db","\u110c\u116c\u11be"],["\uc8dc","\u110c\u116c\u11bf"],["\uc8dd","\u110c\u116c\u11c0"],["\uc8de","\u110c\u116c\u11c1"],["\uc8df","\u110c\u116c\u11c2"],["\uc8e0","\u110c\u116d"],["\uc8e1","\u110c\u116d\u11a8"],["\uc8e2","\u110c\u116d\u11a9"],["\uc8e3","\u110c\u116d\u11aa"],["\uc8e4","\u110c\u116d\u11ab"],["\uc8e5","\u110c\u116d\u11ac"],["\uc8e6","\u110c\u116d\u11ad"],["\uc8e7","\u110c\u116d\u11ae"],["\uc8e8","\u110c\u116d\u11af"],["\uc8e9","\u110c\u116d\u11b0"],["\uc8ea","\u110c\u116d\u11b1"],["\uc8eb","\u110c\u116d\u11b2"],["\uc8ec","\u110c\u116d\u11b3"],["\uc8ed","\u110c\u116d\u11b4"],["\uc8ee","\u110c\u116d\u11b5"],["\uc8ef","\u110c\u116d\u11b6"],["\uc8f0","\u110c\u116d\u11b7"],["\uc8f1","\u110c\u116d\u11b8"],["\uc8f2","\u110c\u116d\u11b9"],["\uc8f3","\u110c\u116d\u11ba"],["\uc8f4","\u110c\u116d\u11bb"],["\uc8f5","\u110c\u116d\u11bc"],["\uc8f6","\u110c\u116d\u11bd"],["\uc8f7","\u110c\u116d\u11be"],["\uc8f8","\u110c\u116d\u11bf"],["\uc8f9","\u110c\u116d\u11c0"],["\uc8fa","\u110c\u116d\u11c1"],["\uc8fb","\u110c\u116d\u11c2"],["\uc8fc","\u110c\u116e"],["\uc8fd","\u110c\u116e\u11a8"],["\uc8fe","\u110c\u116e\u11a9"],["\uc8ff","\u110c\u116e\u11aa"],["\uc900","\u110c\u116e\u11ab"],["\uc901","\u110c\u116e\u11ac"],["\uc902","\u110c\u116e\u11ad"],["\uc903","\u110c\u116e\u11ae"],["\uc904","\u110c\u116e\u11af"],["\uc905","\u110c\u116e\u11b0"],["\uc906","\u110c\u116e\u11b1"],["\uc907","\u110c\u116e\u11b2"],["\uc908","\u110c\u116e\u11b3"],["\uc909","\u110c\u116e\u11b4"],["\uc90a","\u110c\u116e\u11b5"],["\uc90b","\u110c\u116e\u11b6"],["\uc90c","\u110c\u116e\u11b7"],["\uc90d","\u110c\u116e\u11b8"],["\uc90e","\u110c\u116e\u11b9"],["\uc90f","\u110c\u116e\u11ba"],["\uc910","\u110c\u116e\u11bb"],["\uc911","\u110c\u116e\u11bc"],["\uc912","\u110c\u116e\u11bd"],["\uc913","\u110c\u116e\u11be"],["\uc914","\u110c\u116e\u11bf"],["\uc915","\u110c\u116e\u11c0"],["\uc916","\u110c\u116e\u11c1"],["\uc917","\u110c\u116e\u11c2"],["\uc918","\u110c\u116f"],["\uc919","\u110c\u116f\u11a8"],["\uc91a","\u110c\u116f\u11a9"],["\uc91b","\u110c\u116f\u11aa"],["\uc91c","\u110c\u116f\u11ab"],["\uc91d","\u110c\u116f\u11ac"],["\uc91e","\u110c\u116f\u11ad"],["\uc91f","\u110c\u116f\u11ae"],["\uc920","\u110c\u116f\u11af"],["\uc921","\u110c\u116f\u11b0"],["\uc922","\u110c\u116f\u11b1"],["\uc923","\u110c\u116f\u11b2"],["\uc924","\u110c\u116f\u11b3"],["\uc925","\u110c\u116f\u11b4"],["\uc926","\u110c\u116f\u11b5"],["\uc927","\u110c\u116f\u11b6"],["\uc928","\u110c\u116f\u11b7"],["\uc929","\u110c\u116f\u11b8"],["\uc92a","\u110c\u116f\u11b9"],["\uc92b","\u110c\u116f\u11ba"],["\uc92c","\u110c\u116f\u11bb"],["\uc92d","\u110c\u116f\u11bc"],["\uc92e","\u110c\u116f\u11bd"],["\uc92f","\u110c\u116f\u11be"],["\uc930","\u110c\u116f\u11bf"],["\uc931","\u110c\u116f\u11c0"],["\uc932","\u110c\u116f\u11c1"],["\uc933","\u110c\u116f\u11c2"],["\uc934","\u110c\u1170"],["\uc935","\u110c\u1170\u11a8"],["\uc936","\u110c\u1170\u11a9"],["\uc937","\u110c\u1170\u11aa"],["\uc938","\u110c\u1170\u11ab"],["\uc939","\u110c\u1170\u11ac"],["\uc93a","\u110c\u1170\u11ad"],["\uc93b","\u110c\u1170\u11ae"],["\uc93c","\u110c\u1170\u11af"],["\uc93d","\u110c\u1170\u11b0"],["\uc93e","\u110c\u1170\u11b1"],["\uc93f","\u110c\u1170\u11b2"],["\uc940","\u110c\u1170\u11b3"],["\uc941","\u110c\u1170\u11b4"],["\uc942","\u110c\u1170\u11b5"],["\uc943","\u110c\u1170\u11b6"],["\uc944","\u110c\u1170\u11b7"],["\uc945","\u110c\u1170\u11b8"],["\uc946","\u110c\u1170\u11b9"],["\uc947","\u110c\u1170\u11ba"],["\uc948","\u110c\u1170\u11bb"],["\uc949","\u110c\u1170\u11bc"],["\uc94a","\u110c\u1170\u11bd"],["\uc94b","\u110c\u1170\u11be"],["\uc94c","\u110c\u1170\u11bf"],["\uc94d","\u110c\u1170\u11c0"],["\uc94e","\u110c\u1170\u11c1"],["\uc94f","\u110c\u1170\u11c2"],["\uc950","\u110c\u1171"],["\uc951","\u110c\u1171\u11a8"],["\uc952","\u110c\u1171\u11a9"],["\uc953","\u110c\u1171\u11aa"],["\uc954","\u110c\u1171\u11ab"],["\uc955","\u110c\u1171\u11ac"],["\uc956","\u110c\u1171\u11ad"],["\uc957","\u110c\u1171\u11ae"],["\uc958","\u110c\u1171\u11af"],["\uc959","\u110c\u1171\u11b0"],["\uc95a","\u110c\u1171\u11b1"],["\uc95b","\u110c\u1171\u11b2"],["\uc95c","\u110c\u1171\u11b3"],["\uc95d","\u110c\u1171\u11b4"],["\uc95e","\u110c\u1171\u11b5"],["\uc95f","\u110c\u1171\u11b6"],["\uc960","\u110c\u1171\u11b7"],["\uc961","\u110c\u1171\u11b8"],["\uc962","\u110c\u1171\u11b9"],["\uc963","\u110c\u1171\u11ba"],["\uc964","\u110c\u1171\u11bb"],["\uc965","\u110c\u1171\u11bc"],["\uc966","\u110c\u1171\u11bd"],["\uc967","\u110c\u1171\u11be"],["\uc968","\u110c\u1171\u11bf"],["\uc969","\u110c\u1171\u11c0"],["\uc96a","\u110c\u1171\u11c1"],["\uc96b","\u110c\u1171\u11c2"],["\uc96c","\u110c\u1172"],["\uc96d","\u110c\u1172\u11a8"],["\uc96e","\u110c\u1172\u11a9"],["\uc96f","\u110c\u1172\u11aa"],["\uc970","\u110c\u1172\u11ab"],["\uc971","\u110c\u1172\u11ac"],["\uc972","\u110c\u1172\u11ad"],["\uc973","\u110c\u1172\u11ae"],["\uc974","\u110c\u1172\u11af"],["\uc975","\u110c\u1172\u11b0"],["\uc976","\u110c\u1172\u11b1"],["\uc977","\u110c\u1172\u11b2"],["\uc978","\u110c\u1172\u11b3"],["\uc979","\u110c\u1172\u11b4"],["\uc97a","\u110c\u1172\u11b5"],["\uc97b","\u110c\u1172\u11b6"],["\uc97c","\u110c\u1172\u11b7"],["\uc97d","\u110c\u1172\u11b8"],["\uc97e","\u110c\u1172\u11b9"],["\uc97f","\u110c\u1172\u11ba"],["\uc980","\u110c\u1172\u11bb"],["\uc981","\u110c\u1172\u11bc"],["\uc982","\u110c\u1172\u11bd"],["\uc983","\u110c\u1172\u11be"],["\uc984","\u110c\u1172\u11bf"],["\uc985","\u110c\u1172\u11c0"],["\uc986","\u110c\u1172\u11c1"],["\uc987","\u110c\u1172\u11c2"],["\uc988","\u110c\u1173"],["\uc989","\u110c\u1173\u11a8"],["\uc98a","\u110c\u1173\u11a9"],["\uc98b","\u110c\u1173\u11aa"],["\uc98c","\u110c\u1173\u11ab"],["\uc98d","\u110c\u1173\u11ac"],["\uc98e","\u110c\u1173\u11ad"],["\uc98f","\u110c\u1173\u11ae"],["\uc990","\u110c\u1173\u11af"],["\uc991","\u110c\u1173\u11b0"],["\uc992","\u110c\u1173\u11b1"],["\uc993","\u110c\u1173\u11b2"],["\uc994","\u110c\u1173\u11b3"],["\uc995","\u110c\u1173\u11b4"],["\uc996","\u110c\u1173\u11b5"],["\uc997","\u110c\u1173\u11b6"],["\uc998","\u110c\u1173\u11b7"],["\uc999","\u110c\u1173\u11b8"],["\uc99a","\u110c\u1173\u11b9"],["\uc99b","\u110c\u1173\u11ba"],["\uc99c","\u110c\u1173\u11bb"],["\uc99d","\u110c\u1173\u11bc"],["\uc99e","\u110c\u1173\u11bd"],["\uc99f","\u110c\u1173\u11be"],["\uc9a0","\u110c\u1173\u11bf"],["\uc9a1","\u110c\u1173\u11c0"],["\uc9a2","\u110c\u1173\u11c1"],["\uc9a3","\u110c\u1173\u11c2"],["\uc9a4","\u110c\u1174"],["\uc9a5","\u110c\u1174\u11a8"],["\uc9a6","\u110c\u1174\u11a9"],["\uc9a7","\u110c\u1174\u11aa"],["\uc9a8","\u110c\u1174\u11ab"],["\uc9a9","\u110c\u1174\u11ac"],["\uc9aa","\u110c\u1174\u11ad"],["\uc9ab","\u110c\u1174\u11ae"],["\uc9ac","\u110c\u1174\u11af"],["\uc9ad","\u110c\u1174\u11b0"],["\uc9ae","\u110c\u1174\u11b1"],["\uc9af","\u110c\u1174\u11b2"],["\uc9b0","\u110c\u1174\u11b3"],["\uc9b1","\u110c\u1174\u11b4"],["\uc9b2","\u110c\u1174\u11b5"],["\uc9b3","\u110c\u1174\u11b6"],["\uc9b4","\u110c\u1174\u11b7"],["\uc9b5","\u110c\u1174\u11b8"],["\uc9b6","\u110c\u1174\u11b9"],["\uc9b7","\u110c\u1174\u11ba"],["\uc9b8","\u110c\u1174\u11bb"],["\uc9b9","\u110c\u1174\u11bc"],["\uc9ba","\u110c\u1174\u11bd"],["\uc9bb","\u110c\u1174\u11be"],["\uc9bc","\u110c\u1174\u11bf"],["\uc9bd","\u110c\u1174\u11c0"],["\uc9be","\u110c\u1174\u11c1"],["\uc9bf","\u110c\u1174\u11c2"],["\uc9c0","\u110c\u1175"],["\uc9c1","\u110c\u1175\u11a8"],["\uc9c2","\u110c\u1175\u11a9"],["\uc9c3","\u110c\u1175\u11aa"],["\uc9c4","\u110c\u1175\u11ab"],["\uc9c5","\u110c\u1175\u11ac"],["\uc9c6","\u110c\u1175\u11ad"],["\uc9c7","\u110c\u1175\u11ae"],["\uc9c8","\u110c\u1175\u11af"],["\uc9c9","\u110c\u1175\u11b0"],["\uc9ca","\u110c\u1175\u11b1"],["\uc9cb","\u110c\u1175\u11b2"],["\uc9cc","\u110c\u1175\u11b3"],["\uc9cd","\u110c\u1175\u11b4"],["\uc9ce","\u110c\u1175\u11b5"],["\uc9cf","\u110c\u1175\u11b6"],["\uc9d0","\u110c\u1175\u11b7"],["\uc9d1","\u110c\u1175\u11b8"],["\uc9d2","\u110c\u1175\u11b9"],["\uc9d3","\u110c\u1175\u11ba"],["\uc9d4","\u110c\u1175\u11bb"],["\uc9d5","\u110c\u1175\u11bc"],["\uc9d6","\u110c\u1175\u11bd"],["\uc9d7","\u110c\u1175\u11be"],["\uc9d8","\u110c\u1175\u11bf"],["\uc9d9","\u110c\u1175\u11c0"],["\uc9da","\u110c\u1175\u11c1"],["\uc9db","\u110c\u1175\u11c2"],["\uc9dc","\u110d\u1161"],["\uc9dd","\u110d\u1161\u11a8"],["\uc9de","\u110d\u1161\u11a9"],["\uc9df","\u110d\u1161\u11aa"],["\uc9e0","\u110d\u1161\u11ab"],["\uc9e1","\u110d\u1161\u11ac"],["\uc9e2","\u110d\u1161\u11ad"],["\uc9e3","\u110d\u1161\u11ae"],["\uc9e4","\u110d\u1161\u11af"],["\uc9e5","\u110d\u1161\u11b0"],["\uc9e6","\u110d\u1161\u11b1"],["\uc9e7","\u110d\u1161\u11b2"],["\uc9e8","\u110d\u1161\u11b3"],["\uc9e9","\u110d\u1161\u11b4"],["\uc9ea","\u110d\u1161\u11b5"],["\uc9eb","\u110d\u1161\u11b6"],["\uc9ec","\u110d\u1161\u11b7"],["\uc9ed","\u110d\u1161\u11b8"],["\uc9ee","\u110d\u1161\u11b9"],["\uc9ef","\u110d\u1161\u11ba"],["\uc9f0","\u110d\u1161\u11bb"],["\uc9f1","\u110d\u1161\u11bc"],["\uc9f2","\u110d\u1161\u11bd"],["\uc9f3","\u110d\u1161\u11be"],["\uc9f4","\u110d\u1161\u11bf"],["\uc9f5","\u110d\u1161\u11c0"],["\uc9f6","\u110d\u1161\u11c1"],["\uc9f7","\u110d\u1161\u11c2"],["\uc9f8","\u110d\u1162"],["\uc9f9","\u110d\u1162\u11a8"],["\uc9fa","\u110d\u1162\u11a9"],["\uc9fb","\u110d\u1162\u11aa"],["\uc9fc","\u110d\u1162\u11ab"],["\uc9fd","\u110d\u1162\u11ac"],["\uc9fe","\u110d\u1162\u11ad"],["\uc9ff","\u110d\u1162\u11ae"],["\uca00","\u110d\u1162\u11af"],["\uca01","\u110d\u1162\u11b0"],["\uca02","\u110d\u1162\u11b1"],["\uca03","\u110d\u1162\u11b2"],["\uca04","\u110d\u1162\u11b3"],["\uca05","\u110d\u1162\u11b4"],["\uca06","\u110d\u1162\u11b5"],["\uca07","\u110d\u1162\u11b6"],["\uca08","\u110d\u1162\u11b7"],["\uca09","\u110d\u1162\u11b8"],["\uca0a","\u110d\u1162\u11b9"],["\uca0b","\u110d\u1162\u11ba"],["\uca0c","\u110d\u1162\u11bb"],["\uca0d","\u110d\u1162\u11bc"],["\uca0e","\u110d\u1162\u11bd"],["\uca0f","\u110d\u1162\u11be"],["\uca10","\u110d\u1162\u11bf"],["\uca11","\u110d\u1162\u11c0"],["\uca12","\u110d\u1162\u11c1"],["\uca13","\u110d\u1162\u11c2"],["\uca14","\u110d\u1163"],["\uca15","\u110d\u1163\u11a8"],["\uca16","\u110d\u1163\u11a9"],["\uca17","\u110d\u1163\u11aa"],["\uca18","\u110d\u1163\u11ab"],["\uca19","\u110d\u1163\u11ac"],["\uca1a","\u110d\u1163\u11ad"],["\uca1b","\u110d\u1163\u11ae"],["\uca1c","\u110d\u1163\u11af"],["\uca1d","\u110d\u1163\u11b0"],["\uca1e","\u110d\u1163\u11b1"],["\uca1f","\u110d\u1163\u11b2"],["\uca20","\u110d\u1163\u11b3"],["\uca21","\u110d\u1163\u11b4"],["\uca22","\u110d\u1163\u11b5"],["\uca23","\u110d\u1163\u11b6"],["\uca24","\u110d\u1163\u11b7"],["\uca25","\u110d\u1163\u11b8"],["\uca26","\u110d\u1163\u11b9"],["\uca27","\u110d\u1163\u11ba"],["\uca28","\u110d\u1163\u11bb"],["\uca29","\u110d\u1163\u11bc"],["\uca2a","\u110d\u1163\u11bd"],["\uca2b","\u110d\u1163\u11be"],["\uca2c","\u110d\u1163\u11bf"],["\uca2d","\u110d\u1163\u11c0"],["\uca2e","\u110d\u1163\u11c1"],["\uca2f","\u110d\u1163\u11c2"],["\uca30","\u110d\u1164"],["\uca31","\u110d\u1164\u11a8"],["\uca32","\u110d\u1164\u11a9"],["\uca33","\u110d\u1164\u11aa"],["\uca34","\u110d\u1164\u11ab"],["\uca35","\u110d\u1164\u11ac"],["\uca36","\u110d\u1164\u11ad"],["\uca37","\u110d\u1164\u11ae"],["\uca38","\u110d\u1164\u11af"],["\uca39","\u110d\u1164\u11b0"],["\uca3a","\u110d\u1164\u11b1"],["\uca3b","\u110d\u1164\u11b2"],["\uca3c","\u110d\u1164\u11b3"],["\uca3d","\u110d\u1164\u11b4"],["\uca3e","\u110d\u1164\u11b5"],["\uca3f","\u110d\u1164\u11b6"],["\uca40","\u110d\u1164\u11b7"],["\uca41","\u110d\u1164\u11b8"],["\uca42","\u110d\u1164\u11b9"],["\uca43","\u110d\u1164\u11ba"],["\uca44","\u110d\u1164\u11bb"],["\uca45","\u110d\u1164\u11bc"],["\uca46","\u110d\u1164\u11bd"],["\uca47","\u110d\u1164\u11be"],["\uca48","\u110d\u1164\u11bf"],["\uca49","\u110d\u1164\u11c0"],["\uca4a","\u110d\u1164\u11c1"],["\uca4b","\u110d\u1164\u11c2"],["\uca4c","\u110d\u1165"],["\uca4d","\u110d\u1165\u11a8"],["\uca4e","\u110d\u1165\u11a9"],["\uca4f","\u110d\u1165\u11aa"],["\uca50","\u110d\u1165\u11ab"],["\uca51","\u110d\u1165\u11ac"],["\uca52","\u110d\u1165\u11ad"],["\uca53","\u110d\u1165\u11ae"],["\uca54","\u110d\u1165\u11af"],["\uca55","\u110d\u1165\u11b0"],["\uca56","\u110d\u1165\u11b1"],["\uca57","\u110d\u1165\u11b2"],["\uca58","\u110d\u1165\u11b3"],["\uca59","\u110d\u1165\u11b4"],["\uca5a","\u110d\u1165\u11b5"],["\uca5b","\u110d\u1165\u11b6"],["\uca5c","\u110d\u1165\u11b7"],["\uca5d","\u110d\u1165\u11b8"],["\uca5e","\u110d\u1165\u11b9"],["\uca5f","\u110d\u1165\u11ba"],["\uca60","\u110d\u1165\u11bb"],["\uca61","\u110d\u1165\u11bc"],["\uca62","\u110d\u1165\u11bd"],["\uca63","\u110d\u1165\u11be"],["\uca64","\u110d\u1165\u11bf"],["\uca65","\u110d\u1165\u11c0"],["\uca66","\u110d\u1165\u11c1"],["\uca67","\u110d\u1165\u11c2"],["\uca68","\u110d\u1166"],["\uca69","\u110d\u1166\u11a8"],["\uca6a","\u110d\u1166\u11a9"],["\uca6b","\u110d\u1166\u11aa"],["\uca6c","\u110d\u1166\u11ab"],["\uca6d","\u110d\u1166\u11ac"],["\uca6e","\u110d\u1166\u11ad"],["\uca6f","\u110d\u1166\u11ae"],["\uca70","\u110d\u1166\u11af"],["\uca71","\u110d\u1166\u11b0"],["\uca72","\u110d\u1166\u11b1"],["\uca73","\u110d\u1166\u11b2"],["\uca74","\u110d\u1166\u11b3"],["\uca75","\u110d\u1166\u11b4"],["\uca76","\u110d\u1166\u11b5"],["\uca77","\u110d\u1166\u11b6"],["\uca78","\u110d\u1166\u11b7"],["\uca79","\u110d\u1166\u11b8"],["\uca7a","\u110d\u1166\u11b9"],["\uca7b","\u110d\u1166\u11ba"],["\uca7c","\u110d\u1166\u11bb"],["\uca7d","\u110d\u1166\u11bc"],["\uca7e","\u110d\u1166\u11bd"],["\uca7f","\u110d\u1166\u11be"],["\uca80","\u110d\u1166\u11bf"],["\uca81","\u110d\u1166\u11c0"],["\uca82","\u110d\u1166\u11c1"],["\uca83","\u110d\u1166\u11c2"],["\uca84","\u110d\u1167"],["\uca85","\u110d\u1167\u11a8"],["\uca86","\u110d\u1167\u11a9"],["\uca87","\u110d\u1167\u11aa"],["\uca88","\u110d\u1167\u11ab"],["\uca89","\u110d\u1167\u11ac"],["\uca8a","\u110d\u1167\u11ad"],["\uca8b","\u110d\u1167\u11ae"],["\uca8c","\u110d\u1167\u11af"],["\uca8d","\u110d\u1167\u11b0"],["\uca8e","\u110d\u1167\u11b1"],["\uca8f","\u110d\u1167\u11b2"],["\uca90","\u110d\u1167\u11b3"],["\uca91","\u110d\u1167\u11b4"],["\uca92","\u110d\u1167\u11b5"],["\uca93","\u110d\u1167\u11b6"],["\uca94","\u110d\u1167\u11b7"],["\uca95","\u110d\u1167\u11b8"],["\uca96","\u110d\u1167\u11b9"],["\uca97","\u110d\u1167\u11ba"],["\uca98","\u110d\u1167\u11bb"],["\uca99","\u110d\u1167\u11bc"],["\uca9a","\u110d\u1167\u11bd"],["\uca9b","\u110d\u1167\u11be"],["\uca9c","\u110d\u1167\u11bf"],["\uca9d","\u110d\u1167\u11c0"],["\uca9e","\u110d\u1167\u11c1"],["\uca9f","\u110d\u1167\u11c2"],["\ucaa0","\u110d\u1168"],["\ucaa1","\u110d\u1168\u11a8"],["\ucaa2","\u110d\u1168\u11a9"],["\ucaa3","\u110d\u1168\u11aa"],["\ucaa4","\u110d\u1168\u11ab"],["\ucaa5","\u110d\u1168\u11ac"],["\ucaa6","\u110d\u1168\u11ad"],["\ucaa7","\u110d\u1168\u11ae"],["\ucaa8","\u110d\u1168\u11af"],["\ucaa9","\u110d\u1168\u11b0"],["\ucaaa","\u110d\u1168\u11b1"],["\ucaab","\u110d\u1168\u11b2"],["\ucaac","\u110d\u1168\u11b3"],["\ucaad","\u110d\u1168\u11b4"],["\ucaae","\u110d\u1168\u11b5"],["\ucaaf","\u110d\u1168\u11b6"],["\ucab0","\u110d\u1168\u11b7"],["\ucab1","\u110d\u1168\u11b8"],["\ucab2","\u110d\u1168\u11b9"],["\ucab3","\u110d\u1168\u11ba"],["\ucab4","\u110d\u1168\u11bb"],["\ucab5","\u110d\u1168\u11bc"],["\ucab6","\u110d\u1168\u11bd"],["\ucab7","\u110d\u1168\u11be"],["\ucab8","\u110d\u1168\u11bf"],["\ucab9","\u110d\u1168\u11c0"],["\ucaba","\u110d\u1168\u11c1"],["\ucabb","\u110d\u1168\u11c2"],["\ucabc","\u110d\u1169"],["\ucabd","\u110d\u1169\u11a8"],["\ucabe","\u110d\u1169\u11a9"],["\ucabf","\u110d\u1169\u11aa"],["\ucac0","\u110d\u1169\u11ab"],["\ucac1","\u110d\u1169\u11ac"],["\ucac2","\u110d\u1169\u11ad"],["\ucac3","\u110d\u1169\u11ae"],["\ucac4","\u110d\u1169\u11af"],["\ucac5","\u110d\u1169\u11b0"],["\ucac6","\u110d\u1169\u11b1"],["\ucac7","\u110d\u1169\u11b2"],["\ucac8","\u110d\u1169\u11b3"],["\ucac9","\u110d\u1169\u11b4"],["\ucaca","\u110d\u1169\u11b5"],["\ucacb","\u110d\u1169\u11b6"],["\ucacc","\u110d\u1169\u11b7"],["\ucacd","\u110d\u1169\u11b8"],["\ucace","\u110d\u1169\u11b9"],["\ucacf","\u110d\u1169\u11ba"],["\ucad0","\u110d\u1169\u11bb"],["\ucad1","\u110d\u1169\u11bc"],["\ucad2","\u110d\u1169\u11bd"],["\ucad3","\u110d\u1169\u11be"],["\ucad4","\u110d\u1169\u11bf"],["\ucad5","\u110d\u1169\u11c0"],["\ucad6","\u110d\u1169\u11c1"],["\ucad7","\u110d\u1169\u11c2"],["\ucad8","\u110d\u116a"],["\ucad9","\u110d\u116a\u11a8"],["\ucada","\u110d\u116a\u11a9"],["\ucadb","\u110d\u116a\u11aa"],["\ucadc","\u110d\u116a\u11ab"],["\ucadd","\u110d\u116a\u11ac"],["\ucade","\u110d\u116a\u11ad"],["\ucadf","\u110d\u116a\u11ae"],["\ucae0","\u110d\u116a\u11af"],["\ucae1","\u110d\u116a\u11b0"],["\ucae2","\u110d\u116a\u11b1"],["\ucae3","\u110d\u116a\u11b2"],["\ucae4","\u110d\u116a\u11b3"],["\ucae5","\u110d\u116a\u11b4"],["\ucae6","\u110d\u116a\u11b5"],["\ucae7","\u110d\u116a\u11b6"],["\ucae8","\u110d\u116a\u11b7"],["\ucae9","\u110d\u116a\u11b8"],["\ucaea","\u110d\u116a\u11b9"],["\ucaeb","\u110d\u116a\u11ba"],["\ucaec","\u110d\u116a\u11bb"],["\ucaed","\u110d\u116a\u11bc"],["\ucaee","\u110d\u116a\u11bd"],["\ucaef","\u110d\u116a\u11be"],["\ucaf0","\u110d\u116a\u11bf"],["\ucaf1","\u110d\u116a\u11c0"],["\ucaf2","\u110d\u116a\u11c1"],["\ucaf3","\u110d\u116a\u11c2"],["\ucaf4","\u110d\u116b"],["\ucaf5","\u110d\u116b\u11a8"],["\ucaf6","\u110d\u116b\u11a9"],["\ucaf7","\u110d\u116b\u11aa"],["\ucaf8","\u110d\u116b\u11ab"],["\ucaf9","\u110d\u116b\u11ac"],["\ucafa","\u110d\u116b\u11ad"],["\ucafb","\u110d\u116b\u11ae"],["\ucafc","\u110d\u116b\u11af"],["\ucafd","\u110d\u116b\u11b0"],["\ucafe","\u110d\u116b\u11b1"],["\ucaff","\u110d\u116b\u11b2"],["\ucb00","\u110d\u116b\u11b3"],["\ucb01","\u110d\u116b\u11b4"],["\ucb02","\u110d\u116b\u11b5"],["\ucb03","\u110d\u116b\u11b6"],["\ucb04","\u110d\u116b\u11b7"],["\ucb05","\u110d\u116b\u11b8"],["\ucb06","\u110d\u116b\u11b9"],["\ucb07","\u110d\u116b\u11ba"],["\ucb08","\u110d\u116b\u11bb"],["\ucb09","\u110d\u116b\u11bc"],["\ucb0a","\u110d\u116b\u11bd"],["\ucb0b","\u110d\u116b\u11be"],["\ucb0c","\u110d\u116b\u11bf"],["\ucb0d","\u110d\u116b\u11c0"],["\ucb0e","\u110d\u116b\u11c1"],["\ucb0f","\u110d\u116b\u11c2"],["\ucb10","\u110d\u116c"],["\ucb11","\u110d\u116c\u11a8"],["\ucb12","\u110d\u116c\u11a9"],["\ucb13","\u110d\u116c\u11aa"],["\ucb14","\u110d\u116c\u11ab"],["\ucb15","\u110d\u116c\u11ac"],["\ucb16","\u110d\u116c\u11ad"],["\ucb17","\u110d\u116c\u11ae"],["\ucb18","\u110d\u116c\u11af"],["\ucb19","\u110d\u116c\u11b0"],["\ucb1a","\u110d\u116c\u11b1"],["\ucb1b","\u110d\u116c\u11b2"],["\ucb1c","\u110d\u116c\u11b3"],["\ucb1d","\u110d\u116c\u11b4"],["\ucb1e","\u110d\u116c\u11b5"],["\ucb1f","\u110d\u116c\u11b6"],["\ucb20","\u110d\u116c\u11b7"],["\ucb21","\u110d\u116c\u11b8"],["\ucb22","\u110d\u116c\u11b9"],["\ucb23","\u110d\u116c\u11ba"],["\ucb24","\u110d\u116c\u11bb"],["\ucb25","\u110d\u116c\u11bc"],["\ucb26","\u110d\u116c\u11bd"],["\ucb27","\u110d\u116c\u11be"],["\ucb28","\u110d\u116c\u11bf"],["\ucb29","\u110d\u116c\u11c0"],["\ucb2a","\u110d\u116c\u11c1"],["\ucb2b","\u110d\u116c\u11c2"],["\ucb2c","\u110d\u116d"],["\ucb2d","\u110d\u116d\u11a8"],["\ucb2e","\u110d\u116d\u11a9"],["\ucb2f","\u110d\u116d\u11aa"],["\ucb30","\u110d\u116d\u11ab"],["\ucb31","\u110d\u116d\u11ac"],["\ucb32","\u110d\u116d\u11ad"],["\ucb33","\u110d\u116d\u11ae"],["\ucb34","\u110d\u116d\u11af"],["\ucb35","\u110d\u116d\u11b0"],["\ucb36","\u110d\u116d\u11b1"],["\ucb37","\u110d\u116d\u11b2"],["\ucb38","\u110d\u116d\u11b3"],["\ucb39","\u110d\u116d\u11b4"],["\ucb3a","\u110d\u116d\u11b5"],["\ucb3b","\u110d\u116d\u11b6"],["\ucb3c","\u110d\u116d\u11b7"],["\ucb3d","\u110d\u116d\u11b8"],["\ucb3e","\u110d\u116d\u11b9"],["\ucb3f","\u110d\u116d\u11ba"],["\ucb40","\u110d\u116d\u11bb"],["\ucb41","\u110d\u116d\u11bc"],["\ucb42","\u110d\u116d\u11bd"],["\ucb43","\u110d\u116d\u11be"],["\ucb44","\u110d\u116d\u11bf"],["\ucb45","\u110d\u116d\u11c0"],["\ucb46","\u110d\u116d\u11c1"],["\ucb47","\u110d\u116d\u11c2"],["\ucb48","\u110d\u116e"],["\ucb49","\u110d\u116e\u11a8"],["\ucb4a","\u110d\u116e\u11a9"],["\ucb4b","\u110d\u116e\u11aa"],["\ucb4c","\u110d\u116e\u11ab"],["\ucb4d","\u110d\u116e\u11ac"],["\ucb4e","\u110d\u116e\u11ad"],["\ucb4f","\u110d\u116e\u11ae"],["\ucb50","\u110d\u116e\u11af"],["\ucb51","\u110d\u116e\u11b0"],["\ucb52","\u110d\u116e\u11b1"],["\ucb53","\u110d\u116e\u11b2"],["\ucb54","\u110d\u116e\u11b3"],["\ucb55","\u110d\u116e\u11b4"],["\ucb56","\u110d\u116e\u11b5"],["\ucb57","\u110d\u116e\u11b6"],["\ucb58","\u110d\u116e\u11b7"],["\ucb59","\u110d\u116e\u11b8"],["\ucb5a","\u110d\u116e\u11b9"],["\ucb5b","\u110d\u116e\u11ba"],["\ucb5c","\u110d\u116e\u11bb"],["\ucb5d","\u110d\u116e\u11bc"],["\ucb5e","\u110d\u116e\u11bd"],["\ucb5f","\u110d\u116e\u11be"],["\ucb60","\u110d\u116e\u11bf"],["\ucb61","\u110d\u116e\u11c0"],["\ucb62","\u110d\u116e\u11c1"],["\ucb63","\u110d\u116e\u11c2"],["\ucb64","\u110d\u116f"],["\ucb65","\u110d\u116f\u11a8"],["\ucb66","\u110d\u116f\u11a9"],["\ucb67","\u110d\u116f\u11aa"],["\ucb68","\u110d\u116f\u11ab"],["\ucb69","\u110d\u116f\u11ac"],["\ucb6a","\u110d\u116f\u11ad"],["\ucb6b","\u110d\u116f\u11ae"],["\ucb6c","\u110d\u116f\u11af"],["\ucb6d","\u110d\u116f\u11b0"],["\ucb6e","\u110d\u116f\u11b1"],["\ucb6f","\u110d\u116f\u11b2"],["\ucb70","\u110d\u116f\u11b3"],["\ucb71","\u110d\u116f\u11b4"],["\ucb72","\u110d\u116f\u11b5"],["\ucb73","\u110d\u116f\u11b6"],["\ucb74","\u110d\u116f\u11b7"],["\ucb75","\u110d\u116f\u11b8"],["\ucb76","\u110d\u116f\u11b9"],["\ucb77","\u110d\u116f\u11ba"],["\ucb78","\u110d\u116f\u11bb"],["\ucb79","\u110d\u116f\u11bc"],["\ucb7a","\u110d\u116f\u11bd"],["\ucb7b","\u110d\u116f\u11be"],["\ucb7c","\u110d\u116f\u11bf"],["\ucb7d","\u110d\u116f\u11c0"],["\ucb7e","\u110d\u116f\u11c1"],["\ucb7f","\u110d\u116f\u11c2"],["\ucb80","\u110d\u1170"],["\ucb81","\u110d\u1170\u11a8"],["\ucb82","\u110d\u1170\u11a9"],["\ucb83","\u110d\u1170\u11aa"],["\ucb84","\u110d\u1170\u11ab"],["\ucb85","\u110d\u1170\u11ac"],["\ucb86","\u110d\u1170\u11ad"],["\ucb87","\u110d\u1170\u11ae"],["\ucb88","\u110d\u1170\u11af"],["\ucb89","\u110d\u1170\u11b0"],["\ucb8a","\u110d\u1170\u11b1"],["\ucb8b","\u110d\u1170\u11b2"],["\ucb8c","\u110d\u1170\u11b3"],["\ucb8d","\u110d\u1170\u11b4"],["\ucb8e","\u110d\u1170\u11b5"],["\ucb8f","\u110d\u1170\u11b6"],["\ucb90","\u110d\u1170\u11b7"],["\ucb91","\u110d\u1170\u11b8"],["\ucb92","\u110d\u1170\u11b9"],["\ucb93","\u110d\u1170\u11ba"],["\ucb94","\u110d\u1170\u11bb"],["\ucb95","\u110d\u1170\u11bc"],["\ucb96","\u110d\u1170\u11bd"],["\ucb97","\u110d\u1170\u11be"],["\ucb98","\u110d\u1170\u11bf"],["\ucb99","\u110d\u1170\u11c0"],["\ucb9a","\u110d\u1170\u11c1"],["\ucb9b","\u110d\u1170\u11c2"],["\ucb9c","\u110d\u1171"],["\ucb9d","\u110d\u1171\u11a8"],["\ucb9e","\u110d\u1171\u11a9"],["\ucb9f","\u110d\u1171\u11aa"],["\ucba0","\u110d\u1171\u11ab"],["\ucba1","\u110d\u1171\u11ac"],["\ucba2","\u110d\u1171\u11ad"],["\ucba3","\u110d\u1171\u11ae"],["\ucba4","\u110d\u1171\u11af"],["\ucba5","\u110d\u1171\u11b0"],["\ucba6","\u110d\u1171\u11b1"],["\ucba7","\u110d\u1171\u11b2"],["\ucba8","\u110d\u1171\u11b3"],["\ucba9","\u110d\u1171\u11b4"],["\ucbaa","\u110d\u1171\u11b5"],["\ucbab","\u110d\u1171\u11b6"],["\ucbac","\u110d\u1171\u11b7"],["\ucbad","\u110d\u1171\u11b8"],["\ucbae","\u110d\u1171\u11b9"],["\ucbaf","\u110d\u1171\u11ba"],["\ucbb0","\u110d\u1171\u11bb"],["\ucbb1","\u110d\u1171\u11bc"],["\ucbb2","\u110d\u1171\u11bd"],["\ucbb3","\u110d\u1171\u11be"],["\ucbb4","\u110d\u1171\u11bf"],["\ucbb5","\u110d\u1171\u11c0"],["\ucbb6","\u110d\u1171\u11c1"],["\ucbb7","\u110d\u1171\u11c2"],["\ucbb8","\u110d\u1172"],["\ucbb9","\u110d\u1172\u11a8"],["\ucbba","\u110d\u1172\u11a9"],["\ucbbb","\u110d\u1172\u11aa"],["\ucbbc","\u110d\u1172\u11ab"],["\ucbbd","\u110d\u1172\u11ac"],["\ucbbe","\u110d\u1172\u11ad"],["\ucbbf","\u110d\u1172\u11ae"],["\ucbc0","\u110d\u1172\u11af"],["\ucbc1","\u110d\u1172\u11b0"],["\ucbc2","\u110d\u1172\u11b1"],["\ucbc3","\u110d\u1172\u11b2"],["\ucbc4","\u110d\u1172\u11b3"],["\ucbc5","\u110d\u1172\u11b4"],["\ucbc6","\u110d\u1172\u11b5"],["\ucbc7","\u110d\u1172\u11b6"],["\ucbc8","\u110d\u1172\u11b7"],["\ucbc9","\u110d\u1172\u11b8"],["\ucbca","\u110d\u1172\u11b9"],["\ucbcb","\u110d\u1172\u11ba"],["\ucbcc","\u110d\u1172\u11bb"],["\ucbcd","\u110d\u1172\u11bc"],["\ucbce","\u110d\u1172\u11bd"],["\ucbcf","\u110d\u1172\u11be"],["\ucbd0","\u110d\u1172\u11bf"],["\ucbd1","\u110d\u1172\u11c0"],["\ucbd2","\u110d\u1172\u11c1"],["\ucbd3","\u110d\u1172\u11c2"],["\ucbd4","\u110d\u1173"],["\ucbd5","\u110d\u1173\u11a8"],["\ucbd6","\u110d\u1173\u11a9"],["\ucbd7","\u110d\u1173\u11aa"],["\ucbd8","\u110d\u1173\u11ab"],["\ucbd9","\u110d\u1173\u11ac"],["\ucbda","\u110d\u1173\u11ad"],["\ucbdb","\u110d\u1173\u11ae"],["\ucbdc","\u110d\u1173\u11af"],["\ucbdd","\u110d\u1173\u11b0"],["\ucbde","\u110d\u1173\u11b1"],["\ucbdf","\u110d\u1173\u11b2"],["\ucbe0","\u110d\u1173\u11b3"],["\ucbe1","\u110d\u1173\u11b4"],["\ucbe2","\u110d\u1173\u11b5"],["\ucbe3","\u110d\u1173\u11b6"],["\ucbe4","\u110d\u1173\u11b7"],["\ucbe5","\u110d\u1173\u11b8"],["\ucbe6","\u110d\u1173\u11b9"],["\ucbe7","\u110d\u1173\u11ba"],["\ucbe8","\u110d\u1173\u11bb"],["\ucbe9","\u110d\u1173\u11bc"],["\ucbea","\u110d\u1173\u11bd"],["\ucbeb","\u110d\u1173\u11be"],["\ucbec","\u110d\u1173\u11bf"],["\ucbed","\u110d\u1173\u11c0"],["\ucbee","\u110d\u1173\u11c1"],["\ucbef","\u110d\u1173\u11c2"],["\ucbf0","\u110d\u1174"],["\ucbf1","\u110d\u1174\u11a8"],["\ucbf2","\u110d\u1174\u11a9"],["\ucbf3","\u110d\u1174\u11aa"],["\ucbf4","\u110d\u1174\u11ab"],["\ucbf5","\u110d\u1174\u11ac"],["\ucbf6","\u110d\u1174\u11ad"],["\ucbf7","\u110d\u1174\u11ae"],["\ucbf8","\u110d\u1174\u11af"],["\ucbf9","\u110d\u1174\u11b0"],["\ucbfa","\u110d\u1174\u11b1"],["\ucbfb","\u110d\u1174\u11b2"],["\ucbfc","\u110d\u1174\u11b3"],["\ucbfd","\u110d\u1174\u11b4"],["\ucbfe","\u110d\u1174\u11b5"],["\ucbff","\u110d\u1174\u11b6"],["\ucc00","\u110d\u1174\u11b7"],["\ucc01","\u110d\u1174\u11b8"],["\ucc02","\u110d\u1174\u11b9"],["\ucc03","\u110d\u1174\u11ba"],["\ucc04","\u110d\u1174\u11bb"],["\ucc05","\u110d\u1174\u11bc"],["\ucc06","\u110d\u1174\u11bd"],["\ucc07","\u110d\u1174\u11be"],["\ucc08","\u110d\u1174\u11bf"],["\ucc09","\u110d\u1174\u11c0"],["\ucc0a","\u110d\u1174\u11c1"],["\ucc0b","\u110d\u1174\u11c2"],["\ucc0c","\u110d\u1175"],["\ucc0d","\u110d\u1175\u11a8"],["\ucc0e","\u110d\u1175\u11a9"],["\ucc0f","\u110d\u1175\u11aa"],["\ucc10","\u110d\u1175\u11ab"],["\ucc11","\u110d\u1175\u11ac"],["\ucc12","\u110d\u1175\u11ad"],["\ucc13","\u110d\u1175\u11ae"],["\ucc14","\u110d\u1175\u11af"],["\ucc15","\u110d\u1175\u11b0"],["\ucc16","\u110d\u1175\u11b1"],["\ucc17","\u110d\u1175\u11b2"],["\ucc18","\u110d\u1175\u11b3"],["\ucc19","\u110d\u1175\u11b4"],["\ucc1a","\u110d\u1175\u11b5"],["\ucc1b","\u110d\u1175\u11b6"],["\ucc1c","\u110d\u1175\u11b7"],["\ucc1d","\u110d\u1175\u11b8"],["\ucc1e","\u110d\u1175\u11b9"],["\ucc1f","\u110d\u1175\u11ba"],["\ucc20","\u110d\u1175\u11bb"],["\ucc21","\u110d\u1175\u11bc"],["\ucc22","\u110d\u1175\u11bd"],["\ucc23","\u110d\u1175\u11be"],["\ucc24","\u110d\u1175\u11bf"],["\ucc25","\u110d\u1175\u11c0"],["\ucc26","\u110d\u1175\u11c1"],["\ucc27","\u110d\u1175\u11c2"],["\ucc28","\u110e\u1161"],["\ucc29","\u110e\u1161\u11a8"],["\ucc2a","\u110e\u1161\u11a9"],["\ucc2b","\u110e\u1161\u11aa"],["\ucc2c","\u110e\u1161\u11ab"],["\ucc2d","\u110e\u1161\u11ac"],["\ucc2e","\u110e\u1161\u11ad"],["\ucc2f","\u110e\u1161\u11ae"],["\ucc30","\u110e\u1161\u11af"],["\ucc31","\u110e\u1161\u11b0"],["\ucc32","\u110e\u1161\u11b1"],["\ucc33","\u110e\u1161\u11b2"],["\ucc34","\u110e\u1161\u11b3"],["\ucc35","\u110e\u1161\u11b4"],["\ucc36","\u110e\u1161\u11b5"],["\ucc37","\u110e\u1161\u11b6"],["\ucc38","\u110e\u1161\u11b7"],["\ucc39","\u110e\u1161\u11b8"],["\ucc3a","\u110e\u1161\u11b9"],["\ucc3b","\u110e\u1161\u11ba"],["\ucc3c","\u110e\u1161\u11bb"],["\ucc3d","\u110e\u1161\u11bc"],["\ucc3e","\u110e\u1161\u11bd"],["\ucc3f","\u110e\u1161\u11be"],["\ucc40","\u110e\u1161\u11bf"],["\ucc41","\u110e\u1161\u11c0"],["\ucc42","\u110e\u1161\u11c1"],["\ucc43","\u110e\u1161\u11c2"],["\ucc44","\u110e\u1162"],["\ucc45","\u110e\u1162\u11a8"],["\ucc46","\u110e\u1162\u11a9"],["\ucc47","\u110e\u1162\u11aa"],["\ucc48","\u110e\u1162\u11ab"],["\ucc49","\u110e\u1162\u11ac"],["\ucc4a","\u110e\u1162\u11ad"],["\ucc4b","\u110e\u1162\u11ae"],["\ucc4c","\u110e\u1162\u11af"],["\ucc4d","\u110e\u1162\u11b0"],["\ucc4e","\u110e\u1162\u11b1"],["\ucc4f","\u110e\u1162\u11b2"],["\ucc50","\u110e\u1162\u11b3"],["\ucc51","\u110e\u1162\u11b4"],["\ucc52","\u110e\u1162\u11b5"],["\ucc53","\u110e\u1162\u11b6"],["\ucc54","\u110e\u1162\u11b7"],["\ucc55","\u110e\u1162\u11b8"],["\ucc56","\u110e\u1162\u11b9"],["\ucc57","\u110e\u1162\u11ba"],["\ucc58","\u110e\u1162\u11bb"],["\ucc59","\u110e\u1162\u11bc"],["\ucc5a","\u110e\u1162\u11bd"],["\ucc5b","\u110e\u1162\u11be"],["\ucc5c","\u110e\u1162\u11bf"],["\ucc5d","\u110e\u1162\u11c0"],["\ucc5e","\u110e\u1162\u11c1"],["\ucc5f","\u110e\u1162\u11c2"],["\ucc60","\u110e\u1163"],["\ucc61","\u110e\u1163\u11a8"],["\ucc62","\u110e\u1163\u11a9"],["\ucc63","\u110e\u1163\u11aa"],["\ucc64","\u110e\u1163\u11ab"],["\ucc65","\u110e\u1163\u11ac"],["\ucc66","\u110e\u1163\u11ad"],["\ucc67","\u110e\u1163\u11ae"],["\ucc68","\u110e\u1163\u11af"],["\ucc69","\u110e\u1163\u11b0"],["\ucc6a","\u110e\u1163\u11b1"],["\ucc6b","\u110e\u1163\u11b2"],["\ucc6c","\u110e\u1163\u11b3"],["\ucc6d","\u110e\u1163\u11b4"],["\ucc6e","\u110e\u1163\u11b5"],["\ucc6f","\u110e\u1163\u11b6"],["\ucc70","\u110e\u1163\u11b7"],["\ucc71","\u110e\u1163\u11b8"],["\ucc72","\u110e\u1163\u11b9"],["\ucc73","\u110e\u1163\u11ba"],["\ucc74","\u110e\u1163\u11bb"],["\ucc75","\u110e\u1163\u11bc"],["\ucc76","\u110e\u1163\u11bd"],["\ucc77","\u110e\u1163\u11be"],["\ucc78","\u110e\u1163\u11bf"],["\ucc79","\u110e\u1163\u11c0"],["\ucc7a","\u110e\u1163\u11c1"],["\ucc7b","\u110e\u1163\u11c2"],["\ucc7c","\u110e\u1164"],["\ucc7d","\u110e\u1164\u11a8"],["\ucc7e","\u110e\u1164\u11a9"],["\ucc7f","\u110e\u1164\u11aa"],["\ucc80","\u110e\u1164\u11ab"],["\ucc81","\u110e\u1164\u11ac"],["\ucc82","\u110e\u1164\u11ad"],["\ucc83","\u110e\u1164\u11ae"],["\ucc84","\u110e\u1164\u11af"],["\ucc85","\u110e\u1164\u11b0"],["\ucc86","\u110e\u1164\u11b1"],["\ucc87","\u110e\u1164\u11b2"],["\ucc88","\u110e\u1164\u11b3"],["\ucc89","\u110e\u1164\u11b4"],["\ucc8a","\u110e\u1164\u11b5"],["\ucc8b","\u110e\u1164\u11b6"],["\ucc8c","\u110e\u1164\u11b7"],["\ucc8d","\u110e\u1164\u11b8"],["\ucc8e","\u110e\u1164\u11b9"],["\ucc8f","\u110e\u1164\u11ba"],["\ucc90","\u110e\u1164\u11bb"],["\ucc91","\u110e\u1164\u11bc"],["\ucc92","\u110e\u1164\u11bd"],["\ucc93","\u110e\u1164\u11be"],["\ucc94","\u110e\u1164\u11bf"],["\ucc95","\u110e\u1164\u11c0"],["\ucc96","\u110e\u1164\u11c1"],["\ucc97","\u110e\u1164\u11c2"],["\ucc98","\u110e\u1165"],["\ucc99","\u110e\u1165\u11a8"],["\ucc9a","\u110e\u1165\u11a9"],["\ucc9b","\u110e\u1165\u11aa"],["\ucc9c","\u110e\u1165\u11ab"],["\ucc9d","\u110e\u1165\u11ac"],["\ucc9e","\u110e\u1165\u11ad"],["\ucc9f","\u110e\u1165\u11ae"],["\ucca0","\u110e\u1165\u11af"],["\ucca1","\u110e\u1165\u11b0"],["\ucca2","\u110e\u1165\u11b1"],["\ucca3","\u110e\u1165\u11b2"],["\ucca4","\u110e\u1165\u11b3"],["\ucca5","\u110e\u1165\u11b4"],["\ucca6","\u110e\u1165\u11b5"],["\ucca7","\u110e\u1165\u11b6"],["\ucca8","\u110e\u1165\u11b7"],["\ucca9","\u110e\u1165\u11b8"],["\uccaa","\u110e\u1165\u11b9"],["\uccab","\u110e\u1165\u11ba"],["\uccac","\u110e\u1165\u11bb"],["\uccad","\u110e\u1165\u11bc"],["\uccae","\u110e\u1165\u11bd"],["\uccaf","\u110e\u1165\u11be"],["\uccb0","\u110e\u1165\u11bf"],["\uccb1","\u110e\u1165\u11c0"],["\uccb2","\u110e\u1165\u11c1"],["\uccb3","\u110e\u1165\u11c2"],["\uccb4","\u110e\u1166"],["\uccb5","\u110e\u1166\u11a8"],["\uccb6","\u110e\u1166\u11a9"],["\uccb7","\u110e\u1166\u11aa"],["\uccb8","\u110e\u1166\u11ab"],["\uccb9","\u110e\u1166\u11ac"],["\uccba","\u110e\u1166\u11ad"],["\uccbb","\u110e\u1166\u11ae"],["\uccbc","\u110e\u1166\u11af"],["\uccbd","\u110e\u1166\u11b0"],["\uccbe","\u110e\u1166\u11b1"],["\uccbf","\u110e\u1166\u11b2"],["\uccc0","\u110e\u1166\u11b3"],["\uccc1","\u110e\u1166\u11b4"],["\uccc2","\u110e\u1166\u11b5"],["\uccc3","\u110e\u1166\u11b6"],["\uccc4","\u110e\u1166\u11b7"],["\uccc5","\u110e\u1166\u11b8"],["\uccc6","\u110e\u1166\u11b9"],["\uccc7","\u110e\u1166\u11ba"],["\uccc8","\u110e\u1166\u11bb"],["\uccc9","\u110e\u1166\u11bc"],["\uccca","\u110e\u1166\u11bd"],["\ucccb","\u110e\u1166\u11be"],["\ucccc","\u110e\u1166\u11bf"],["\ucccd","\u110e\u1166\u11c0"],["\uccce","\u110e\u1166\u11c1"],["\ucccf","\u110e\u1166\u11c2"],["\uccd0","\u110e\u1167"],["\uccd1","\u110e\u1167\u11a8"],["\uccd2","\u110e\u1167\u11a9"],["\uccd3","\u110e\u1167\u11aa"],["\uccd4","\u110e\u1167\u11ab"],["\uccd5","\u110e\u1167\u11ac"],["\uccd6","\u110e\u1167\u11ad"],["\uccd7","\u110e\u1167\u11ae"],["\uccd8","\u110e\u1167\u11af"],["\uccd9","\u110e\u1167\u11b0"],["\uccda","\u110e\u1167\u11b1"],["\uccdb","\u110e\u1167\u11b2"],["\uccdc","\u110e\u1167\u11b3"],["\uccdd","\u110e\u1167\u11b4"],["\uccde","\u110e\u1167\u11b5"],["\uccdf","\u110e\u1167\u11b6"],["\ucce0","\u110e\u1167\u11b7"],["\ucce1","\u110e\u1167\u11b8"],["\ucce2","\u110e\u1167\u11b9"],["\ucce3","\u110e\u1167\u11ba"],["\ucce4","\u110e\u1167\u11bb"],["\ucce5","\u110e\u1167\u11bc"],["\ucce6","\u110e\u1167\u11bd"],["\ucce7","\u110e\u1167\u11be"],["\ucce8","\u110e\u1167\u11bf"],["\ucce9","\u110e\u1167\u11c0"],["\uccea","\u110e\u1167\u11c1"],["\ucceb","\u110e\u1167\u11c2"],["\uccec","\u110e\u1168"],["\ucced","\u110e\u1168\u11a8"],["\uccee","\u110e\u1168\u11a9"],["\uccef","\u110e\u1168\u11aa"],["\uccf0","\u110e\u1168\u11ab"],["\uccf1","\u110e\u1168\u11ac"],["\uccf2","\u110e\u1168\u11ad"],["\uccf3","\u110e\u1168\u11ae"],["\uccf4","\u110e\u1168\u11af"],["\uccf5","\u110e\u1168\u11b0"],["\uccf6","\u110e\u1168\u11b1"],["\uccf7","\u110e\u1168\u11b2"],["\uccf8","\u110e\u1168\u11b3"],["\uccf9","\u110e\u1168\u11b4"],["\uccfa","\u110e\u1168\u11b5"],["\uccfb","\u110e\u1168\u11b6"],["\uccfc","\u110e\u1168\u11b7"],["\uccfd","\u110e\u1168\u11b8"],["\uccfe","\u110e\u1168\u11b9"],["\uccff","\u110e\u1168\u11ba"],["\ucd00","\u110e\u1168\u11bb"],["\ucd01","\u110e\u1168\u11bc"],["\ucd02","\u110e\u1168\u11bd"],["\ucd03","\u110e\u1168\u11be"],["\ucd04","\u110e\u1168\u11bf"],["\ucd05","\u110e\u1168\u11c0"],["\ucd06","\u110e\u1168\u11c1"],["\ucd07","\u110e\u1168\u11c2"],["\ucd08","\u110e\u1169"],["\ucd09","\u110e\u1169\u11a8"],["\ucd0a","\u110e\u1169\u11a9"],["\ucd0b","\u110e\u1169\u11aa"],["\ucd0c","\u110e\u1169\u11ab"],["\ucd0d","\u110e\u1169\u11ac"],["\ucd0e","\u110e\u1169\u11ad"],["\ucd0f","\u110e\u1169\u11ae"],["\ucd10","\u110e\u1169\u11af"],["\ucd11","\u110e\u1169\u11b0"],["\ucd12","\u110e\u1169\u11b1"],["\ucd13","\u110e\u1169\u11b2"],["\ucd14","\u110e\u1169\u11b3"],["\ucd15","\u110e\u1169\u11b4"],["\ucd16","\u110e\u1169\u11b5"],["\ucd17","\u110e\u1169\u11b6"],["\ucd18","\u110e\u1169\u11b7"],["\ucd19","\u110e\u1169\u11b8"],["\ucd1a","\u110e\u1169\u11b9"],["\ucd1b","\u110e\u1169\u11ba"],["\ucd1c","\u110e\u1169\u11bb"],["\ucd1d","\u110e\u1169\u11bc"],["\ucd1e","\u110e\u1169\u11bd"],["\ucd1f","\u110e\u1169\u11be"],["\ucd20","\u110e\u1169\u11bf"],["\ucd21","\u110e\u1169\u11c0"],["\ucd22","\u110e\u1169\u11c1"],["\ucd23","\u110e\u1169\u11c2"],["\ucd24","\u110e\u116a"],["\ucd25","\u110e\u116a\u11a8"],["\ucd26","\u110e\u116a\u11a9"],["\ucd27","\u110e\u116a\u11aa"],["\ucd28","\u110e\u116a\u11ab"],["\ucd29","\u110e\u116a\u11ac"],["\ucd2a","\u110e\u116a\u11ad"],["\ucd2b","\u110e\u116a\u11ae"],["\ucd2c","\u110e\u116a\u11af"],["\ucd2d","\u110e\u116a\u11b0"],["\ucd2e","\u110e\u116a\u11b1"],["\ucd2f","\u110e\u116a\u11b2"],["\ucd30","\u110e\u116a\u11b3"],["\ucd31","\u110e\u116a\u11b4"],["\ucd32","\u110e\u116a\u11b5"],["\ucd33","\u110e\u116a\u11b6"],["\ucd34","\u110e\u116a\u11b7"],["\ucd35","\u110e\u116a\u11b8"],["\ucd36","\u110e\u116a\u11b9"],["\ucd37","\u110e\u116a\u11ba"],["\ucd38","\u110e\u116a\u11bb"],["\ucd39","\u110e\u116a\u11bc"],["\ucd3a","\u110e\u116a\u11bd"],["\ucd3b","\u110e\u116a\u11be"],["\ucd3c","\u110e\u116a\u11bf"],["\ucd3d","\u110e\u116a\u11c0"],["\ucd3e","\u110e\u116a\u11c1"],["\ucd3f","\u110e\u116a\u11c2"],["\ucd40","\u110e\u116b"],["\ucd41","\u110e\u116b\u11a8"],["\ucd42","\u110e\u116b\u11a9"],["\ucd43","\u110e\u116b\u11aa"],["\ucd44","\u110e\u116b\u11ab"],["\ucd45","\u110e\u116b\u11ac"],["\ucd46","\u110e\u116b\u11ad"],["\ucd47","\u110e\u116b\u11ae"],["\ucd48","\u110e\u116b\u11af"],["\ucd49","\u110e\u116b\u11b0"],["\ucd4a","\u110e\u116b\u11b1"],["\ucd4b","\u110e\u116b\u11b2"],["\ucd4c","\u110e\u116b\u11b3"],["\ucd4d","\u110e\u116b\u11b4"],["\ucd4e","\u110e\u116b\u11b5"],["\ucd4f","\u110e\u116b\u11b6"],["\ucd50","\u110e\u116b\u11b7"],["\ucd51","\u110e\u116b\u11b8"],["\ucd52","\u110e\u116b\u11b9"],["\ucd53","\u110e\u116b\u11ba"],["\ucd54","\u110e\u116b\u11bb"],["\ucd55","\u110e\u116b\u11bc"],["\ucd56","\u110e\u116b\u11bd"],["\ucd57","\u110e\u116b\u11be"],["\ucd58","\u110e\u116b\u11bf"],["\ucd59","\u110e\u116b\u11c0"],["\ucd5a","\u110e\u116b\u11c1"],["\ucd5b","\u110e\u116b\u11c2"],["\ucd5c","\u110e\u116c"],["\ucd5d","\u110e\u116c\u11a8"],["\ucd5e","\u110e\u116c\u11a9"],["\ucd5f","\u110e\u116c\u11aa"],["\ucd60","\u110e\u116c\u11ab"],["\ucd61","\u110e\u116c\u11ac"],["\ucd62","\u110e\u116c\u11ad"],["\ucd63","\u110e\u116c\u11ae"],["\ucd64","\u110e\u116c\u11af"],["\ucd65","\u110e\u116c\u11b0"],["\ucd66","\u110e\u116c\u11b1"],["\ucd67","\u110e\u116c\u11b2"],["\ucd68","\u110e\u116c\u11b3"],["\ucd69","\u110e\u116c\u11b4"],["\ucd6a","\u110e\u116c\u11b5"],["\ucd6b","\u110e\u116c\u11b6"],["\ucd6c","\u110e\u116c\u11b7"],["\ucd6d","\u110e\u116c\u11b8"],["\ucd6e","\u110e\u116c\u11b9"],["\ucd6f","\u110e\u116c\u11ba"],["\ucd70","\u110e\u116c\u11bb"],["\ucd71","\u110e\u116c\u11bc"],["\ucd72","\u110e\u116c\u11bd"],["\ucd73","\u110e\u116c\u11be"],["\ucd74","\u110e\u116c\u11bf"],["\ucd75","\u110e\u116c\u11c0"],["\ucd76","\u110e\u116c\u11c1"],["\ucd77","\u110e\u116c\u11c2"],["\ucd78","\u110e\u116d"],["\ucd79","\u110e\u116d\u11a8"],["\ucd7a","\u110e\u116d\u11a9"],["\ucd7b","\u110e\u116d\u11aa"],["\ucd7c","\u110e\u116d\u11ab"],["\ucd7d","\u110e\u116d\u11ac"],["\ucd7e","\u110e\u116d\u11ad"],["\ucd7f","\u110e\u116d\u11ae"],["\ucd80","\u110e\u116d\u11af"],["\ucd81","\u110e\u116d\u11b0"],["\ucd82","\u110e\u116d\u11b1"],["\ucd83","\u110e\u116d\u11b2"],["\ucd84","\u110e\u116d\u11b3"],["\ucd85","\u110e\u116d\u11b4"],["\ucd86","\u110e\u116d\u11b5"],["\ucd87","\u110e\u116d\u11b6"],["\ucd88","\u110e\u116d\u11b7"],["\ucd89","\u110e\u116d\u11b8"],["\ucd8a","\u110e\u116d\u11b9"],["\ucd8b","\u110e\u116d\u11ba"],["\ucd8c","\u110e\u116d\u11bb"],["\ucd8d","\u110e\u116d\u11bc"],["\ucd8e","\u110e\u116d\u11bd"],["\ucd8f","\u110e\u116d\u11be"],["\ucd90","\u110e\u116d\u11bf"],["\ucd91","\u110e\u116d\u11c0"],["\ucd92","\u110e\u116d\u11c1"],["\ucd93","\u110e\u116d\u11c2"],["\ucd94","\u110e\u116e"],["\ucd95","\u110e\u116e\u11a8"],["\ucd96","\u110e\u116e\u11a9"],["\ucd97","\u110e\u116e\u11aa"],["\ucd98","\u110e\u116e\u11ab"],["\ucd99","\u110e\u116e\u11ac"],["\ucd9a","\u110e\u116e\u11ad"],["\ucd9b","\u110e\u116e\u11ae"],["\ucd9c","\u110e\u116e\u11af"],["\ucd9d","\u110e\u116e\u11b0"],["\ucd9e","\u110e\u116e\u11b1"],["\ucd9f","\u110e\u116e\u11b2"],["\ucda0","\u110e\u116e\u11b3"],["\ucda1","\u110e\u116e\u11b4"],["\ucda2","\u110e\u116e\u11b5"],["\ucda3","\u110e\u116e\u11b6"],["\ucda4","\u110e\u116e\u11b7"],["\ucda5","\u110e\u116e\u11b8"],["\ucda6","\u110e\u116e\u11b9"],["\ucda7","\u110e\u116e\u11ba"],["\ucda8","\u110e\u116e\u11bb"],["\ucda9","\u110e\u116e\u11bc"],["\ucdaa","\u110e\u116e\u11bd"],["\ucdab","\u110e\u116e\u11be"],["\ucdac","\u110e\u116e\u11bf"],["\ucdad","\u110e\u116e\u11c0"],["\ucdae","\u110e\u116e\u11c1"],["\ucdaf","\u110e\u116e\u11c2"],["\ucdb0","\u110e\u116f"],["\ucdb1","\u110e\u116f\u11a8"],["\ucdb2","\u110e\u116f\u11a9"],["\ucdb3","\u110e\u116f\u11aa"],["\ucdb4","\u110e\u116f\u11ab"],["\ucdb5","\u110e\u116f\u11ac"],["\ucdb6","\u110e\u116f\u11ad"],["\ucdb7","\u110e\u116f\u11ae"],["\ucdb8","\u110e\u116f\u11af"],["\ucdb9","\u110e\u116f\u11b0"],["\ucdba","\u110e\u116f\u11b1"],["\ucdbb","\u110e\u116f\u11b2"],["\ucdbc","\u110e\u116f\u11b3"],["\ucdbd","\u110e\u116f\u11b4"],["\ucdbe","\u110e\u116f\u11b5"],["\ucdbf","\u110e\u116f\u11b6"],["\ucdc0","\u110e\u116f\u11b7"],["\ucdc1","\u110e\u116f\u11b8"],["\ucdc2","\u110e\u116f\u11b9"],["\ucdc3","\u110e\u116f\u11ba"],["\ucdc4","\u110e\u116f\u11bb"],["\ucdc5","\u110e\u116f\u11bc"],["\ucdc6","\u110e\u116f\u11bd"],["\ucdc7","\u110e\u116f\u11be"],["\ucdc8","\u110e\u116f\u11bf"],["\ucdc9","\u110e\u116f\u11c0"],["\ucdca","\u110e\u116f\u11c1"],["\ucdcb","\u110e\u116f\u11c2"],["\ucdcc","\u110e\u1170"],["\ucdcd","\u110e\u1170\u11a8"],["\ucdce","\u110e\u1170\u11a9"],["\ucdcf","\u110e\u1170\u11aa"],["\ucdd0","\u110e\u1170\u11ab"],["\ucdd1","\u110e\u1170\u11ac"],["\ucdd2","\u110e\u1170\u11ad"],["\ucdd3","\u110e\u1170\u11ae"],["\ucdd4","\u110e\u1170\u11af"],["\ucdd5","\u110e\u1170\u11b0"],["\ucdd6","\u110e\u1170\u11b1"],["\ucdd7","\u110e\u1170\u11b2"],["\ucdd8","\u110e\u1170\u11b3"],["\ucdd9","\u110e\u1170\u11b4"],["\ucdda","\u110e\u1170\u11b5"],["\ucddb","\u110e\u1170\u11b6"],["\ucddc","\u110e\u1170\u11b7"],["\ucddd","\u110e\u1170\u11b8"],["\ucdde","\u110e\u1170\u11b9"],["\ucddf","\u110e\u1170\u11ba"],["\ucde0","\u110e\u1170\u11bb"],["\ucde1","\u110e\u1170\u11bc"],["\ucde2","\u110e\u1170\u11bd"],["\ucde3","\u110e\u1170\u11be"],["\ucde4","\u110e\u1170\u11bf"],["\ucde5","\u110e\u1170\u11c0"],["\ucde6","\u110e\u1170\u11c1"],["\ucde7","\u110e\u1170\u11c2"],["\ucde8","\u110e\u1171"],["\ucde9","\u110e\u1171\u11a8"],["\ucdea","\u110e\u1171\u11a9"],["\ucdeb","\u110e\u1171\u11aa"],["\ucdec","\u110e\u1171\u11ab"],["\ucded","\u110e\u1171\u11ac"],["\ucdee","\u110e\u1171\u11ad"],["\ucdef","\u110e\u1171\u11ae"],["\ucdf0","\u110e\u1171\u11af"],["\ucdf1","\u110e\u1171\u11b0"],["\ucdf2","\u110e\u1171\u11b1"],["\ucdf3","\u110e\u1171\u11b2"],["\ucdf4","\u110e\u1171\u11b3"],["\ucdf5","\u110e\u1171\u11b4"],["\ucdf6","\u110e\u1171\u11b5"],["\ucdf7","\u110e\u1171\u11b6"],["\ucdf8","\u110e\u1171\u11b7"],["\ucdf9","\u110e\u1171\u11b8"],["\ucdfa","\u110e\u1171\u11b9"],["\ucdfb","\u110e\u1171\u11ba"],["\ucdfc","\u110e\u1171\u11bb"],["\ucdfd","\u110e\u1171\u11bc"],["\ucdfe","\u110e\u1171\u11bd"],["\ucdff","\u110e\u1171\u11be"],["\uce00","\u110e\u1171\u11bf"],["\uce01","\u110e\u1171\u11c0"],["\uce02","\u110e\u1171\u11c1"],["\uce03","\u110e\u1171\u11c2"],["\uce04","\u110e\u1172"],["\uce05","\u110e\u1172\u11a8"],["\uce06","\u110e\u1172\u11a9"],["\uce07","\u110e\u1172\u11aa"],["\uce08","\u110e\u1172\u11ab"],["\uce09","\u110e\u1172\u11ac"],["\uce0a","\u110e\u1172\u11ad"],["\uce0b","\u110e\u1172\u11ae"],["\uce0c","\u110e\u1172\u11af"],["\uce0d","\u110e\u1172\u11b0"],["\uce0e","\u110e\u1172\u11b1"],["\uce0f","\u110e\u1172\u11b2"],["\uce10","\u110e\u1172\u11b3"],["\uce11","\u110e\u1172\u11b4"],["\uce12","\u110e\u1172\u11b5"],["\uce13","\u110e\u1172\u11b6"],["\uce14","\u110e\u1172\u11b7"],["\uce15","\u110e\u1172\u11b8"],["\uce16","\u110e\u1172\u11b9"],["\uce17","\u110e\u1172\u11ba"],["\uce18","\u110e\u1172\u11bb"],["\uce19","\u110e\u1172\u11bc"],["\uce1a","\u110e\u1172\u11bd"],["\uce1b","\u110e\u1172\u11be"],["\uce1c","\u110e\u1172\u11bf"],["\uce1d","\u110e\u1172\u11c0"],["\uce1e","\u110e\u1172\u11c1"],["\uce1f","\u110e\u1172\u11c2"],["\uce20","\u110e\u1173"],["\uce21","\u110e\u1173\u11a8"],["\uce22","\u110e\u1173\u11a9"],["\uce23","\u110e\u1173\u11aa"],["\uce24","\u110e\u1173\u11ab"],["\uce25","\u110e\u1173\u11ac"],["\uce26","\u110e\u1173\u11ad"],["\uce27","\u110e\u1173\u11ae"],["\uce28","\u110e\u1173\u11af"],["\uce29","\u110e\u1173\u11b0"],["\uce2a","\u110e\u1173\u11b1"],["\uce2b","\u110e\u1173\u11b2"],["\uce2c","\u110e\u1173\u11b3"],["\uce2d","\u110e\u1173\u11b4"],["\uce2e","\u110e\u1173\u11b5"],["\uce2f","\u110e\u1173\u11b6"],["\uce30","\u110e\u1173\u11b7"],["\uce31","\u110e\u1173\u11b8"],["\uce32","\u110e\u1173\u11b9"],["\uce33","\u110e\u1173\u11ba"],["\uce34","\u110e\u1173\u11bb"],["\uce35","\u110e\u1173\u11bc"],["\uce36","\u110e\u1173\u11bd"],["\uce37","\u110e\u1173\u11be"],["\uce38","\u110e\u1173\u11bf"],["\uce39","\u110e\u1173\u11c0"],["\uce3a","\u110e\u1173\u11c1"],["\uce3b","\u110e\u1173\u11c2"],["\uce3c","\u110e\u1174"],["\uce3d","\u110e\u1174\u11a8"],["\uce3e","\u110e\u1174\u11a9"],["\uce3f","\u110e\u1174\u11aa"],["\uce40","\u110e\u1174\u11ab"],["\uce41","\u110e\u1174\u11ac"],["\uce42","\u110e\u1174\u11ad"],["\uce43","\u110e\u1174\u11ae"],["\uce44","\u110e\u1174\u11af"],["\uce45","\u110e\u1174\u11b0"],["\uce46","\u110e\u1174\u11b1"],["\uce47","\u110e\u1174\u11b2"],["\uce48","\u110e\u1174\u11b3"],["\uce49","\u110e\u1174\u11b4"],["\uce4a","\u110e\u1174\u11b5"],["\uce4b","\u110e\u1174\u11b6"],["\uce4c","\u110e\u1174\u11b7"],["\uce4d","\u110e\u1174\u11b8"],["\uce4e","\u110e\u1174\u11b9"],["\uce4f","\u110e\u1174\u11ba"],["\uce50","\u110e\u1174\u11bb"],["\uce51","\u110e\u1174\u11bc"],["\uce52","\u110e\u1174\u11bd"],["\uce53","\u110e\u1174\u11be"],["\uce54","\u110e\u1174\u11bf"],["\uce55","\u110e\u1174\u11c0"],["\uce56","\u110e\u1174\u11c1"],["\uce57","\u110e\u1174\u11c2"],["\uce58","\u110e\u1175"],["\uce59","\u110e\u1175\u11a8"],["\uce5a","\u110e\u1175\u11a9"],["\uce5b","\u110e\u1175\u11aa"],["\uce5c","\u110e\u1175\u11ab"],["\uce5d","\u110e\u1175\u11ac"],["\uce5e","\u110e\u1175\u11ad"],["\uce5f","\u110e\u1175\u11ae"],["\uce60","\u110e\u1175\u11af"],["\uce61","\u110e\u1175\u11b0"],["\uce62","\u110e\u1175\u11b1"],["\uce63","\u110e\u1175\u11b2"],["\uce64","\u110e\u1175\u11b3"],["\uce65","\u110e\u1175\u11b4"],["\uce66","\u110e\u1175\u11b5"],["\uce67","\u110e\u1175\u11b6"],["\uce68","\u110e\u1175\u11b7"],["\uce69","\u110e\u1175\u11b8"],["\uce6a","\u110e\u1175\u11b9"],["\uce6b","\u110e\u1175\u11ba"],["\uce6c","\u110e\u1175\u11bb"],["\uce6d","\u110e\u1175\u11bc"],["\uce6e","\u110e\u1175\u11bd"],["\uce6f","\u110e\u1175\u11be"],["\uce70","\u110e\u1175\u11bf"],["\uce71","\u110e\u1175\u11c0"],["\uce72","\u110e\u1175\u11c1"],["\uce73","\u110e\u1175\u11c2"],["\uce74","\u110f\u1161"],["\uce75","\u110f\u1161\u11a8"],["\uce76","\u110f\u1161\u11a9"],["\uce77","\u110f\u1161\u11aa"],["\uce78","\u110f\u1161\u11ab"],["\uce79","\u110f\u1161\u11ac"],["\uce7a","\u110f\u1161\u11ad"],["\uce7b","\u110f\u1161\u11ae"],["\uce7c","\u110f\u1161\u11af"],["\uce7d","\u110f\u1161\u11b0"],["\uce7e","\u110f\u1161\u11b1"],["\uce7f","\u110f\u1161\u11b2"],["\uce80","\u110f\u1161\u11b3"],["\uce81","\u110f\u1161\u11b4"],["\uce82","\u110f\u1161\u11b5"],["\uce83","\u110f\u1161\u11b6"],["\uce84","\u110f\u1161\u11b7"],["\uce85","\u110f\u1161\u11b8"],["\uce86","\u110f\u1161\u11b9"],["\uce87","\u110f\u1161\u11ba"],["\uce88","\u110f\u1161\u11bb"],["\uce89","\u110f\u1161\u11bc"],["\uce8a","\u110f\u1161\u11bd"],["\uce8b","\u110f\u1161\u11be"],["\uce8c","\u110f\u1161\u11bf"],["\uce8d","\u110f\u1161\u11c0"],["\uce8e","\u110f\u1161\u11c1"],["\uce8f","\u110f\u1161\u11c2"],["\uce90","\u110f\u1162"],["\uce91","\u110f\u1162\u11a8"],["\uce92","\u110f\u1162\u11a9"],["\uce93","\u110f\u1162\u11aa"],["\uce94","\u110f\u1162\u11ab"],["\uce95","\u110f\u1162\u11ac"],["\uce96","\u110f\u1162\u11ad"],["\uce97","\u110f\u1162\u11ae"],["\uce98","\u110f\u1162\u11af"],["\uce99","\u110f\u1162\u11b0"],["\uce9a","\u110f\u1162\u11b1"],["\uce9b","\u110f\u1162\u11b2"],["\uce9c","\u110f\u1162\u11b3"],["\uce9d","\u110f\u1162\u11b4"],["\uce9e","\u110f\u1162\u11b5"],["\uce9f","\u110f\u1162\u11b6"],["\ucea0","\u110f\u1162\u11b7"],["\ucea1","\u110f\u1162\u11b8"],["\ucea2","\u110f\u1162\u11b9"],["\ucea3","\u110f\u1162\u11ba"],["\ucea4","\u110f\u1162\u11bb"],["\ucea5","\u110f\u1162\u11bc"],["\ucea6","\u110f\u1162\u11bd"],["\ucea7","\u110f\u1162\u11be"],["\ucea8","\u110f\u1162\u11bf"],["\ucea9","\u110f\u1162\u11c0"],["\uceaa","\u110f\u1162\u11c1"],["\uceab","\u110f\u1162\u11c2"],["\uceac","\u110f\u1163"],["\ucead","\u110f\u1163\u11a8"],["\uceae","\u110f\u1163\u11a9"],["\uceaf","\u110f\u1163\u11aa"],["\uceb0","\u110f\u1163\u11ab"],["\uceb1","\u110f\u1163\u11ac"],["\uceb2","\u110f\u1163\u11ad"],["\uceb3","\u110f\u1163\u11ae"],["\uceb4","\u110f\u1163\u11af"],["\uceb5","\u110f\u1163\u11b0"],["\uceb6","\u110f\u1163\u11b1"],["\uceb7","\u110f\u1163\u11b2"],["\uceb8","\u110f\u1163\u11b3"],["\uceb9","\u110f\u1163\u11b4"],["\uceba","\u110f\u1163\u11b5"],["\ucebb","\u110f\u1163\u11b6"],["\ucebc","\u110f\u1163\u11b7"],["\ucebd","\u110f\u1163\u11b8"],["\ucebe","\u110f\u1163\u11b9"],["\ucebf","\u110f\u1163\u11ba"],["\ucec0","\u110f\u1163\u11bb"],["\ucec1","\u110f\u1163\u11bc"],["\ucec2","\u110f\u1163\u11bd"],["\ucec3","\u110f\u1163\u11be"],["\ucec4","\u110f\u1163\u11bf"],["\ucec5","\u110f\u1163\u11c0"],["\ucec6","\u110f\u1163\u11c1"],["\ucec7","\u110f\u1163\u11c2"],["\ucec8","\u110f\u1164"],["\ucec9","\u110f\u1164\u11a8"],["\uceca","\u110f\u1164\u11a9"],["\ucecb","\u110f\u1164\u11aa"],["\ucecc","\u110f\u1164\u11ab"],["\ucecd","\u110f\u1164\u11ac"],["\ucece","\u110f\u1164\u11ad"],["\ucecf","\u110f\u1164\u11ae"],["\uced0","\u110f\u1164\u11af"],["\uced1","\u110f\u1164\u11b0"],["\uced2","\u110f\u1164\u11b1"],["\uced3","\u110f\u1164\u11b2"],["\uced4","\u110f\u1164\u11b3"],["\uced5","\u110f\u1164\u11b4"],["\uced6","\u110f\u1164\u11b5"],["\uced7","\u110f\u1164\u11b6"],["\uced8","\u110f\u1164\u11b7"],["\uced9","\u110f\u1164\u11b8"],["\uceda","\u110f\u1164\u11b9"],["\ucedb","\u110f\u1164\u11ba"],["\ucedc","\u110f\u1164\u11bb"],["\ucedd","\u110f\u1164\u11bc"],["\ucede","\u110f\u1164\u11bd"],["\ucedf","\u110f\u1164\u11be"],["\ucee0","\u110f\u1164\u11bf"],["\ucee1","\u110f\u1164\u11c0"],["\ucee2","\u110f\u1164\u11c1"],["\ucee3","\u110f\u1164\u11c2"],["\ucee4","\u110f\u1165"],["\ucee5","\u110f\u1165\u11a8"],["\ucee6","\u110f\u1165\u11a9"],["\ucee7","\u110f\u1165\u11aa"],["\ucee8","\u110f\u1165\u11ab"],["\ucee9","\u110f\u1165\u11ac"],["\uceea","\u110f\u1165\u11ad"],["\uceeb","\u110f\u1165\u11ae"],["\uceec","\u110f\u1165\u11af"],["\uceed","\u110f\u1165\u11b0"],["\uceee","\u110f\u1165\u11b1"],["\uceef","\u110f\u1165\u11b2"],["\ucef0","\u110f\u1165\u11b3"],["\ucef1","\u110f\u1165\u11b4"],["\ucef2","\u110f\u1165\u11b5"],["\ucef3","\u110f\u1165\u11b6"],["\ucef4","\u110f\u1165\u11b7"],["\ucef5","\u110f\u1165\u11b8"],["\ucef6","\u110f\u1165\u11b9"],["\ucef7","\u110f\u1165\u11ba"],["\ucef8","\u110f\u1165\u11bb"],["\ucef9","\u110f\u1165\u11bc"],["\ucefa","\u110f\u1165\u11bd"],["\ucefb","\u110f\u1165\u11be"],["\ucefc","\u110f\u1165\u11bf"],["\ucefd","\u110f\u1165\u11c0"],["\ucefe","\u110f\u1165\u11c1"],["\uceff","\u110f\u1165\u11c2"],["\ucf00","\u110f\u1166"],["\ucf01","\u110f\u1166\u11a8"],["\ucf02","\u110f\u1166\u11a9"],["\ucf03","\u110f\u1166\u11aa"],["\ucf04","\u110f\u1166\u11ab"],["\ucf05","\u110f\u1166\u11ac"],["\ucf06","\u110f\u1166\u11ad"],["\ucf07","\u110f\u1166\u11ae"],["\ucf08","\u110f\u1166\u11af"],["\ucf09","\u110f\u1166\u11b0"],["\ucf0a","\u110f\u1166\u11b1"],["\ucf0b","\u110f\u1166\u11b2"],["\ucf0c","\u110f\u1166\u11b3"],["\ucf0d","\u110f\u1166\u11b4"],["\ucf0e","\u110f\u1166\u11b5"],["\ucf0f","\u110f\u1166\u11b6"],["\ucf10","\u110f\u1166\u11b7"],["\ucf11","\u110f\u1166\u11b8"],["\ucf12","\u110f\u1166\u11b9"],["\ucf13","\u110f\u1166\u11ba"],["\ucf14","\u110f\u1166\u11bb"],["\ucf15","\u110f\u1166\u11bc"],["\ucf16","\u110f\u1166\u11bd"],["\ucf17","\u110f\u1166\u11be"],["\ucf18","\u110f\u1166\u11bf"],["\ucf19","\u110f\u1166\u11c0"],["\ucf1a","\u110f\u1166\u11c1"],["\ucf1b","\u110f\u1166\u11c2"],["\ucf1c","\u110f\u1167"],["\ucf1d","\u110f\u1167\u11a8"],["\ucf1e","\u110f\u1167\u11a9"],["\ucf1f","\u110f\u1167\u11aa"],["\ucf20","\u110f\u1167\u11ab"],["\ucf21","\u110f\u1167\u11ac"],["\ucf22","\u110f\u1167\u11ad"],["\ucf23","\u110f\u1167\u11ae"],["\ucf24","\u110f\u1167\u11af"],["\ucf25","\u110f\u1167\u11b0"],["\ucf26","\u110f\u1167\u11b1"],["\ucf27","\u110f\u1167\u11b2"],["\ucf28","\u110f\u1167\u11b3"],["\ucf29","\u110f\u1167\u11b4"],["\ucf2a","\u110f\u1167\u11b5"],["\ucf2b","\u110f\u1167\u11b6"],["\ucf2c","\u110f\u1167\u11b7"],["\ucf2d","\u110f\u1167\u11b8"],["\ucf2e","\u110f\u1167\u11b9"],["\ucf2f","\u110f\u1167\u11ba"],["\ucf30","\u110f\u1167\u11bb"],["\ucf31","\u110f\u1167\u11bc"],["\ucf32","\u110f\u1167\u11bd"],["\ucf33","\u110f\u1167\u11be"],["\ucf34","\u110f\u1167\u11bf"],["\ucf35","\u110f\u1167\u11c0"],["\ucf36","\u110f\u1167\u11c1"],["\ucf37","\u110f\u1167\u11c2"],["\ucf38","\u110f\u1168"],["\ucf39","\u110f\u1168\u11a8"],["\ucf3a","\u110f\u1168\u11a9"],["\ucf3b","\u110f\u1168\u11aa"],["\ucf3c","\u110f\u1168\u11ab"],["\ucf3d","\u110f\u1168\u11ac"],["\ucf3e","\u110f\u1168\u11ad"],["\ucf3f","\u110f\u1168\u11ae"],["\ucf40","\u110f\u1168\u11af"],["\ucf41","\u110f\u1168\u11b0"],["\ucf42","\u110f\u1168\u11b1"],["\ucf43","\u110f\u1168\u11b2"],["\ucf44","\u110f\u1168\u11b3"],["\ucf45","\u110f\u1168\u11b4"],["\ucf46","\u110f\u1168\u11b5"],["\ucf47","\u110f\u1168\u11b6"],["\ucf48","\u110f\u1168\u11b7"],["\ucf49","\u110f\u1168\u11b8"],["\ucf4a","\u110f\u1168\u11b9"],["\ucf4b","\u110f\u1168\u11ba"],["\ucf4c","\u110f\u1168\u11bb"],["\ucf4d","\u110f\u1168\u11bc"],["\ucf4e","\u110f\u1168\u11bd"],["\ucf4f","\u110f\u1168\u11be"],["\ucf50","\u110f\u1168\u11bf"],["\ucf51","\u110f\u1168\u11c0"],["\ucf52","\u110f\u1168\u11c1"],["\ucf53","\u110f\u1168\u11c2"],["\ucf54","\u110f\u1169"],["\ucf55","\u110f\u1169\u11a8"],["\ucf56","\u110f\u1169\u11a9"],["\ucf57","\u110f\u1169\u11aa"],["\ucf58","\u110f\u1169\u11ab"],["\ucf59","\u110f\u1169\u11ac"],["\ucf5a","\u110f\u1169\u11ad"],["\ucf5b","\u110f\u1169\u11ae"],["\ucf5c","\u110f\u1169\u11af"],["\ucf5d","\u110f\u1169\u11b0"],["\ucf5e","\u110f\u1169\u11b1"],["\ucf5f","\u110f\u1169\u11b2"],["\ucf60","\u110f\u1169\u11b3"],["\ucf61","\u110f\u1169\u11b4"],["\ucf62","\u110f\u1169\u11b5"],["\ucf63","\u110f\u1169\u11b6"],["\ucf64","\u110f\u1169\u11b7"],["\ucf65","\u110f\u1169\u11b8"],["\ucf66","\u110f\u1169\u11b9"],["\ucf67","\u110f\u1169\u11ba"],["\ucf68","\u110f\u1169\u11bb"],["\ucf69","\u110f\u1169\u11bc"],["\ucf6a","\u110f\u1169\u11bd"],["\ucf6b","\u110f\u1169\u11be"],["\ucf6c","\u110f\u1169\u11bf"],["\ucf6d","\u110f\u1169\u11c0"],["\ucf6e","\u110f\u1169\u11c1"],["\ucf6f","\u110f\u1169\u11c2"],["\ucf70","\u110f\u116a"],["\ucf71","\u110f\u116a\u11a8"],["\ucf72","\u110f\u116a\u11a9"],["\ucf73","\u110f\u116a\u11aa"],["\ucf74","\u110f\u116a\u11ab"],["\ucf75","\u110f\u116a\u11ac"],["\ucf76","\u110f\u116a\u11ad"],["\ucf77","\u110f\u116a\u11ae"],["\ucf78","\u110f\u116a\u11af"],["\ucf79","\u110f\u116a\u11b0"],["\ucf7a","\u110f\u116a\u11b1"],["\ucf7b","\u110f\u116a\u11b2"],["\ucf7c","\u110f\u116a\u11b3"],["\ucf7d","\u110f\u116a\u11b4"],["\ucf7e","\u110f\u116a\u11b5"],["\ucf7f","\u110f\u116a\u11b6"],["\ucf80","\u110f\u116a\u11b7"],["\ucf81","\u110f\u116a\u11b8"],["\ucf82","\u110f\u116a\u11b9"],["\ucf83","\u110f\u116a\u11ba"],["\ucf84","\u110f\u116a\u11bb"],["\ucf85","\u110f\u116a\u11bc"],["\ucf86","\u110f\u116a\u11bd"],["\ucf87","\u110f\u116a\u11be"],["\ucf88","\u110f\u116a\u11bf"],["\ucf89","\u110f\u116a\u11c0"],["\ucf8a","\u110f\u116a\u11c1"],["\ucf8b","\u110f\u116a\u11c2"],["\ucf8c","\u110f\u116b"],["\ucf8d","\u110f\u116b\u11a8"],["\ucf8e","\u110f\u116b\u11a9"],["\ucf8f","\u110f\u116b\u11aa"],["\ucf90","\u110f\u116b\u11ab"],["\ucf91","\u110f\u116b\u11ac"],["\ucf92","\u110f\u116b\u11ad"],["\ucf93","\u110f\u116b\u11ae"],["\ucf94","\u110f\u116b\u11af"],["\ucf95","\u110f\u116b\u11b0"],["\ucf96","\u110f\u116b\u11b1"],["\ucf97","\u110f\u116b\u11b2"],["\ucf98","\u110f\u116b\u11b3"],["\ucf99","\u110f\u116b\u11b4"],["\ucf9a","\u110f\u116b\u11b5"],["\ucf9b","\u110f\u116b\u11b6"],["\ucf9c","\u110f\u116b\u11b7"],["\ucf9d","\u110f\u116b\u11b8"],["\ucf9e","\u110f\u116b\u11b9"],["\ucf9f","\u110f\u116b\u11ba"],["\ucfa0","\u110f\u116b\u11bb"],["\ucfa1","\u110f\u116b\u11bc"],["\ucfa2","\u110f\u116b\u11bd"],["\ucfa3","\u110f\u116b\u11be"],["\ucfa4","\u110f\u116b\u11bf"],["\ucfa5","\u110f\u116b\u11c0"],["\ucfa6","\u110f\u116b\u11c1"],["\ucfa7","\u110f\u116b\u11c2"],["\ucfa8","\u110f\u116c"],["\ucfa9","\u110f\u116c\u11a8"],["\ucfaa","\u110f\u116c\u11a9"],["\ucfab","\u110f\u116c\u11aa"],["\ucfac","\u110f\u116c\u11ab"],["\ucfad","\u110f\u116c\u11ac"],["\ucfae","\u110f\u116c\u11ad"],["\ucfaf","\u110f\u116c\u11ae"],["\ucfb0","\u110f\u116c\u11af"],["\ucfb1","\u110f\u116c\u11b0"],["\ucfb2","\u110f\u116c\u11b1"],["\ucfb3","\u110f\u116c\u11b2"],["\ucfb4","\u110f\u116c\u11b3"],["\ucfb5","\u110f\u116c\u11b4"],["\ucfb6","\u110f\u116c\u11b5"],["\ucfb7","\u110f\u116c\u11b6"],["\ucfb8","\u110f\u116c\u11b7"],["\ucfb9","\u110f\u116c\u11b8"],["\ucfba","\u110f\u116c\u11b9"],["\ucfbb","\u110f\u116c\u11ba"],["\ucfbc","\u110f\u116c\u11bb"],["\ucfbd","\u110f\u116c\u11bc"],["\ucfbe","\u110f\u116c\u11bd"],["\ucfbf","\u110f\u116c\u11be"],["\ucfc0","\u110f\u116c\u11bf"],["\ucfc1","\u110f\u116c\u11c0"],["\ucfc2","\u110f\u116c\u11c1"],["\ucfc3","\u110f\u116c\u11c2"],["\ucfc4","\u110f\u116d"],["\ucfc5","\u110f\u116d\u11a8"],["\ucfc6","\u110f\u116d\u11a9"],["\ucfc7","\u110f\u116d\u11aa"],["\ucfc8","\u110f\u116d\u11ab"],["\ucfc9","\u110f\u116d\u11ac"],["\ucfca","\u110f\u116d\u11ad"],["\ucfcb","\u110f\u116d\u11ae"],["\ucfcc","\u110f\u116d\u11af"],["\ucfcd","\u110f\u116d\u11b0"],["\ucfce","\u110f\u116d\u11b1"],["\ucfcf","\u110f\u116d\u11b2"],["\ucfd0","\u110f\u116d\u11b3"],["\ucfd1","\u110f\u116d\u11b4"],["\ucfd2","\u110f\u116d\u11b5"],["\ucfd3","\u110f\u116d\u11b6"],["\ucfd4","\u110f\u116d\u11b7"],["\ucfd5","\u110f\u116d\u11b8"],["\ucfd6","\u110f\u116d\u11b9"],["\ucfd7","\u110f\u116d\u11ba"],["\ucfd8","\u110f\u116d\u11bb"],["\ucfd9","\u110f\u116d\u11bc"],["\ucfda","\u110f\u116d\u11bd"],["\ucfdb","\u110f\u116d\u11be"],["\ucfdc","\u110f\u116d\u11bf"],["\ucfdd","\u110f\u116d\u11c0"],["\ucfde","\u110f\u116d\u11c1"],["\ucfdf","\u110f\u116d\u11c2"],["\ucfe0","\u110f\u116e"],["\ucfe1","\u110f\u116e\u11a8"],["\ucfe2","\u110f\u116e\u11a9"],["\ucfe3","\u110f\u116e\u11aa"],["\ucfe4","\u110f\u116e\u11ab"],["\ucfe5","\u110f\u116e\u11ac"],["\ucfe6","\u110f\u116e\u11ad"],["\ucfe7","\u110f\u116e\u11ae"],["\ucfe8","\u110f\u116e\u11af"],["\ucfe9","\u110f\u116e\u11b0"],["\ucfea","\u110f\u116e\u11b1"],["\ucfeb","\u110f\u116e\u11b2"],["\ucfec","\u110f\u116e\u11b3"],["\ucfed","\u110f\u116e\u11b4"],["\ucfee","\u110f\u116e\u11b5"],["\ucfef","\u110f\u116e\u11b6"],["\ucff0","\u110f\u116e\u11b7"],["\ucff1","\u110f\u116e\u11b8"],["\ucff2","\u110f\u116e\u11b9"],["\ucff3","\u110f\u116e\u11ba"],["\ucff4","\u110f\u116e\u11bb"],["\ucff5","\u110f\u116e\u11bc"],["\ucff6","\u110f\u116e\u11bd"],["\ucff7","\u110f\u116e\u11be"],["\ucff8","\u110f\u116e\u11bf"],["\ucff9","\u110f\u116e\u11c0"],["\ucffa","\u110f\u116e\u11c1"],["\ucffb","\u110f\u116e\u11c2"],["\ucffc","\u110f\u116f"],["\ucffd","\u110f\u116f\u11a8"],["\ucffe","\u110f\u116f\u11a9"],["\ucfff","\u110f\u116f\u11aa"],["\ud000","\u110f\u116f\u11ab"],["\ud001","\u110f\u116f\u11ac"],["\ud002","\u110f\u116f\u11ad"],["\ud003","\u110f\u116f\u11ae"],["\ud004","\u110f\u116f\u11af"],["\ud005","\u110f\u116f\u11b0"],["\ud006","\u110f\u116f\u11b1"],["\ud007","\u110f\u116f\u11b2"],["\ud008","\u110f\u116f\u11b3"],["\ud009","\u110f\u116f\u11b4"],["\ud00a","\u110f\u116f\u11b5"],["\ud00b","\u110f\u116f\u11b6"],["\ud00c","\u110f\u116f\u11b7"],["\ud00d","\u110f\u116f\u11b8"],["\ud00e","\u110f\u116f\u11b9"],["\ud00f","\u110f\u116f\u11ba"],["\ud010","\u110f\u116f\u11bb"],["\ud011","\u110f\u116f\u11bc"],["\ud012","\u110f\u116f\u11bd"],["\ud013","\u110f\u116f\u11be"],["\ud014","\u110f\u116f\u11bf"],["\ud015","\u110f\u116f\u11c0"],["\ud016","\u110f\u116f\u11c1"],["\ud017","\u110f\u116f\u11c2"],["\ud018","\u110f\u1170"],["\ud019","\u110f\u1170\u11a8"],["\ud01a","\u110f\u1170\u11a9"],["\ud01b","\u110f\u1170\u11aa"],["\ud01c","\u110f\u1170\u11ab"],["\ud01d","\u110f\u1170\u11ac"],["\ud01e","\u110f\u1170\u11ad"],["\ud01f","\u110f\u1170\u11ae"],["\ud020","\u110f\u1170\u11af"],["\ud021","\u110f\u1170\u11b0"],["\ud022","\u110f\u1170\u11b1"],["\ud023","\u110f\u1170\u11b2"],["\ud024","\u110f\u1170\u11b3"],["\ud025","\u110f\u1170\u11b4"],["\ud026","\u110f\u1170\u11b5"],["\ud027","\u110f\u1170\u11b6"],["\ud028","\u110f\u1170\u11b7"],["\ud029","\u110f\u1170\u11b8"],["\ud02a","\u110f\u1170\u11b9"],["\ud02b","\u110f\u1170\u11ba"],["\ud02c","\u110f\u1170\u11bb"],["\ud02d","\u110f\u1170\u11bc"],["\ud02e","\u110f\u1170\u11bd"],["\ud02f","\u110f\u1170\u11be"],["\ud030","\u110f\u1170\u11bf"],["\ud031","\u110f\u1170\u11c0"],["\ud032","\u110f\u1170\u11c1"],["\ud033","\u110f\u1170\u11c2"],["\ud034","\u110f\u1171"],["\ud035","\u110f\u1171\u11a8"],["\ud036","\u110f\u1171\u11a9"],["\ud037","\u110f\u1171\u11aa"],["\ud038","\u110f\u1171\u11ab"],["\ud039","\u110f\u1171\u11ac"],["\ud03a","\u110f\u1171\u11ad"],["\ud03b","\u110f\u1171\u11ae"],["\ud03c","\u110f\u1171\u11af"],["\ud03d","\u110f\u1171\u11b0"],["\ud03e","\u110f\u1171\u11b1"],["\ud03f","\u110f\u1171\u11b2"],["\ud040","\u110f\u1171\u11b3"],["\ud041","\u110f\u1171\u11b4"],["\ud042","\u110f\u1171\u11b5"],["\ud043","\u110f\u1171\u11b6"],["\ud044","\u110f\u1171\u11b7"],["\ud045","\u110f\u1171\u11b8"],["\ud046","\u110f\u1171\u11b9"],["\ud047","\u110f\u1171\u11ba"],["\ud048","\u110f\u1171\u11bb"],["\ud049","\u110f\u1171\u11bc"],["\ud04a","\u110f\u1171\u11bd"],["\ud04b","\u110f\u1171\u11be"],["\ud04c","\u110f\u1171\u11bf"],["\ud04d","\u110f\u1171\u11c0"],["\ud04e","\u110f\u1171\u11c1"],["\ud04f","\u110f\u1171\u11c2"],["\ud050","\u110f\u1172"],["\ud051","\u110f\u1172\u11a8"],["\ud052","\u110f\u1172\u11a9"],["\ud053","\u110f\u1172\u11aa"],["\ud054","\u110f\u1172\u11ab"],["\ud055","\u110f\u1172\u11ac"],["\ud056","\u110f\u1172\u11ad"],["\ud057","\u110f\u1172\u11ae"],["\ud058","\u110f\u1172\u11af"],["\ud059","\u110f\u1172\u11b0"],["\ud05a","\u110f\u1172\u11b1"],["\ud05b","\u110f\u1172\u11b2"],["\ud05c","\u110f\u1172\u11b3"],["\ud05d","\u110f\u1172\u11b4"],["\ud05e","\u110f\u1172\u11b5"],["\ud05f","\u110f\u1172\u11b6"],["\ud060","\u110f\u1172\u11b7"],["\ud061","\u110f\u1172\u11b8"],["\ud062","\u110f\u1172\u11b9"],["\ud063","\u110f\u1172\u11ba"],["\ud064","\u110f\u1172\u11bb"],["\ud065","\u110f\u1172\u11bc"],["\ud066","\u110f\u1172\u11bd"],["\ud067","\u110f\u1172\u11be"],["\ud068","\u110f\u1172\u11bf"],["\ud069","\u110f\u1172\u11c0"],["\ud06a","\u110f\u1172\u11c1"],["\ud06b","\u110f\u1172\u11c2"],["\ud06c","\u110f\u1173"],["\ud06d","\u110f\u1173\u11a8"],["\ud06e","\u110f\u1173\u11a9"],["\ud06f","\u110f\u1173\u11aa"],["\ud070","\u110f\u1173\u11ab"],["\ud071","\u110f\u1173\u11ac"],["\ud072","\u110f\u1173\u11ad"],["\ud073","\u110f\u1173\u11ae"],["\ud074","\u110f\u1173\u11af"],["\ud075","\u110f\u1173\u11b0"],["\ud076","\u110f\u1173\u11b1"],["\ud077","\u110f\u1173\u11b2"],["\ud078","\u110f\u1173\u11b3"],["\ud079","\u110f\u1173\u11b4"],["\ud07a","\u110f\u1173\u11b5"],["\ud07b","\u110f\u1173\u11b6"],["\ud07c","\u110f\u1173\u11b7"],["\ud07d","\u110f\u1173\u11b8"],["\ud07e","\u110f\u1173\u11b9"],["\ud07f","\u110f\u1173\u11ba"],["\ud080","\u110f\u1173\u11bb"],["\ud081","\u110f\u1173\u11bc"],["\ud082","\u110f\u1173\u11bd"],["\ud083","\u110f\u1173\u11be"],["\ud084","\u110f\u1173\u11bf"],["\ud085","\u110f\u1173\u11c0"],["\ud086","\u110f\u1173\u11c1"],["\ud087","\u110f\u1173\u11c2"],["\ud088","\u110f\u1174"],["\ud089","\u110f\u1174\u11a8"],["\ud08a","\u110f\u1174\u11a9"],["\ud08b","\u110f\u1174\u11aa"],["\ud08c","\u110f\u1174\u11ab"],["\ud08d","\u110f\u1174\u11ac"],["\ud08e","\u110f\u1174\u11ad"],["\ud08f","\u110f\u1174\u11ae"],["\ud090","\u110f\u1174\u11af"],["\ud091","\u110f\u1174\u11b0"],["\ud092","\u110f\u1174\u11b1"],["\ud093","\u110f\u1174\u11b2"],["\ud094","\u110f\u1174\u11b3"],["\ud095","\u110f\u1174\u11b4"],["\ud096","\u110f\u1174\u11b5"],["\ud097","\u110f\u1174\u11b6"],["\ud098","\u110f\u1174\u11b7"],["\ud099","\u110f\u1174\u11b8"],["\ud09a","\u110f\u1174\u11b9"],["\ud09b","\u110f\u1174\u11ba"],["\ud09c","\u110f\u1174\u11bb"],["\ud09d","\u110f\u1174\u11bc"],["\ud09e","\u110f\u1174\u11bd"],["\ud09f","\u110f\u1174\u11be"],["\ud0a0","\u110f\u1174\u11bf"],["\ud0a1","\u110f\u1174\u11c0"],["\ud0a2","\u110f\u1174\u11c1"],["\ud0a3","\u110f\u1174\u11c2"],["\ud0a4","\u110f\u1175"],["\ud0a5","\u110f\u1175\u11a8"],["\ud0a6","\u110f\u1175\u11a9"],["\ud0a7","\u110f\u1175\u11aa"],["\ud0a8","\u110f\u1175\u11ab"],["\ud0a9","\u110f\u1175\u11ac"],["\ud0aa","\u110f\u1175\u11ad"],["\ud0ab","\u110f\u1175\u11ae"],["\ud0ac","\u110f\u1175\u11af"],["\ud0ad","\u110f\u1175\u11b0"],["\ud0ae","\u110f\u1175\u11b1"],["\ud0af","\u110f\u1175\u11b2"],["\ud0b0","\u110f\u1175\u11b3"],["\ud0b1","\u110f\u1175\u11b4"],["\ud0b2","\u110f\u1175\u11b5"],["\ud0b3","\u110f\u1175\u11b6"],["\ud0b4","\u110f\u1175\u11b7"],["\ud0b5","\u110f\u1175\u11b8"],["\ud0b6","\u110f\u1175\u11b9"],["\ud0b7","\u110f\u1175\u11ba"],["\ud0b8","\u110f\u1175\u11bb"],["\ud0b9","\u110f\u1175\u11bc"],["\ud0ba","\u110f\u1175\u11bd"],["\ud0bb","\u110f\u1175\u11be"],["\ud0bc","\u110f\u1175\u11bf"],["\ud0bd","\u110f\u1175\u11c0"],["\ud0be","\u110f\u1175\u11c1"],["\ud0bf","\u110f\u1175\u11c2"],["\ud0c0","\u1110\u1161"],["\ud0c1","\u1110\u1161\u11a8"],["\ud0c2","\u1110\u1161\u11a9"],["\ud0c3","\u1110\u1161\u11aa"],["\ud0c4","\u1110\u1161\u11ab"],["\ud0c5","\u1110\u1161\u11ac"],["\ud0c6","\u1110\u1161\u11ad"],["\ud0c7","\u1110\u1161\u11ae"],["\ud0c8","\u1110\u1161\u11af"],["\ud0c9","\u1110\u1161\u11b0"],["\ud0ca","\u1110\u1161\u11b1"],["\ud0cb","\u1110\u1161\u11b2"],["\ud0cc","\u1110\u1161\u11b3"],["\ud0cd","\u1110\u1161\u11b4"],["\ud0ce","\u1110\u1161\u11b5"],["\ud0cf","\u1110\u1161\u11b6"],["\ud0d0","\u1110\u1161\u11b7"],["\ud0d1","\u1110\u1161\u11b8"],["\ud0d2","\u1110\u1161\u11b9"],["\ud0d3","\u1110\u1161\u11ba"],["\ud0d4","\u1110\u1161\u11bb"],["\ud0d5","\u1110\u1161\u11bc"],["\ud0d6","\u1110\u1161\u11bd"],["\ud0d7","\u1110\u1161\u11be"],["\ud0d8","\u1110\u1161\u11bf"],["\ud0d9","\u1110\u1161\u11c0"],["\ud0da","\u1110\u1161\u11c1"],["\ud0db","\u1110\u1161\u11c2"],["\ud0dc","\u1110\u1162"],["\ud0dd","\u1110\u1162\u11a8"],["\ud0de","\u1110\u1162\u11a9"],["\ud0df","\u1110\u1162\u11aa"],["\ud0e0","\u1110\u1162\u11ab"],["\ud0e1","\u1110\u1162\u11ac"],["\ud0e2","\u1110\u1162\u11ad"],["\ud0e3","\u1110\u1162\u11ae"],["\ud0e4","\u1110\u1162\u11af"],["\ud0e5","\u1110\u1162\u11b0"],["\ud0e6","\u1110\u1162\u11b1"],["\ud0e7","\u1110\u1162\u11b2"],["\ud0e8","\u1110\u1162\u11b3"],["\ud0e9","\u1110\u1162\u11b4"],["\ud0ea","\u1110\u1162\u11b5"],["\ud0eb","\u1110\u1162\u11b6"],["\ud0ec","\u1110\u1162\u11b7"],["\ud0ed","\u1110\u1162\u11b8"],["\ud0ee","\u1110\u1162\u11b9"],["\ud0ef","\u1110\u1162\u11ba"],["\ud0f0","\u1110\u1162\u11bb"],["\ud0f1","\u1110\u1162\u11bc"],["\ud0f2","\u1110\u1162\u11bd"],["\ud0f3","\u1110\u1162\u11be"],["\ud0f4","\u1110\u1162\u11bf"],["\ud0f5","\u1110\u1162\u11c0"],["\ud0f6","\u1110\u1162\u11c1"],["\ud0f7","\u1110\u1162\u11c2"],["\ud0f8","\u1110\u1163"],["\ud0f9","\u1110\u1163\u11a8"],["\ud0fa","\u1110\u1163\u11a9"],["\ud0fb","\u1110\u1163\u11aa"],["\ud0fc","\u1110\u1163\u11ab"],["\ud0fd","\u1110\u1163\u11ac"],["\ud0fe","\u1110\u1163\u11ad"],["\ud0ff","\u1110\u1163\u11ae"],["\ud100","\u1110\u1163\u11af"],["\ud101","\u1110\u1163\u11b0"],["\ud102","\u1110\u1163\u11b1"],["\ud103","\u1110\u1163\u11b2"],["\ud104","\u1110\u1163\u11b3"],["\ud105","\u1110\u1163\u11b4"],["\ud106","\u1110\u1163\u11b5"],["\ud107","\u1110\u1163\u11b6"],["\ud108","\u1110\u1163\u11b7"],["\ud109","\u1110\u1163\u11b8"],["\ud10a","\u1110\u1163\u11b9"],["\ud10b","\u1110\u1163\u11ba"],["\ud10c","\u1110\u1163\u11bb"],["\ud10d","\u1110\u1163\u11bc"],["\ud10e","\u1110\u1163\u11bd"],["\ud10f","\u1110\u1163\u11be"],["\ud110","\u1110\u1163\u11bf"],["\ud111","\u1110\u1163\u11c0"],["\ud112","\u1110\u1163\u11c1"],["\ud113","\u1110\u1163\u11c2"],["\ud114","\u1110\u1164"],["\ud115","\u1110\u1164\u11a8"],["\ud116","\u1110\u1164\u11a9"],["\ud117","\u1110\u1164\u11aa"],["\ud118","\u1110\u1164\u11ab"],["\ud119","\u1110\u1164\u11ac"],["\ud11a","\u1110\u1164\u11ad"],["\ud11b","\u1110\u1164\u11ae"],["\ud11c","\u1110\u1164\u11af"],["\ud11d","\u1110\u1164\u11b0"],["\ud11e","\u1110\u1164\u11b1"],["\ud11f","\u1110\u1164\u11b2"],["\ud120","\u1110\u1164\u11b3"],["\ud121","\u1110\u1164\u11b4"],["\ud122","\u1110\u1164\u11b5"],["\ud123","\u1110\u1164\u11b6"],["\ud124","\u1110\u1164\u11b7"],["\ud125","\u1110\u1164\u11b8"],["\ud126","\u1110\u1164\u11b9"],["\ud127","\u1110\u1164\u11ba"],["\ud128","\u1110\u1164\u11bb"],["\ud129","\u1110\u1164\u11bc"],["\ud12a","\u1110\u1164\u11bd"],["\ud12b","\u1110\u1164\u11be"],["\ud12c","\u1110\u1164\u11bf"],["\ud12d","\u1110\u1164\u11c0"],["\ud12e","\u1110\u1164\u11c1"],["\ud12f","\u1110\u1164\u11c2"],["\ud130","\u1110\u1165"],["\ud131","\u1110\u1165\u11a8"],["\ud132","\u1110\u1165\u11a9"],["\ud133","\u1110\u1165\u11aa"],["\ud134","\u1110\u1165\u11ab"],["\ud135","\u1110\u1165\u11ac"],["\ud136","\u1110\u1165\u11ad"],["\ud137","\u1110\u1165\u11ae"],["\ud138","\u1110\u1165\u11af"],["\ud139","\u1110\u1165\u11b0"],["\ud13a","\u1110\u1165\u11b1"],["\ud13b","\u1110\u1165\u11b2"],["\ud13c","\u1110\u1165\u11b3"],["\ud13d","\u1110\u1165\u11b4"],["\ud13e","\u1110\u1165\u11b5"],["\ud13f","\u1110\u1165\u11b6"],["\ud140","\u1110\u1165\u11b7"],["\ud141","\u1110\u1165\u11b8"],["\ud142","\u1110\u1165\u11b9"],["\ud143","\u1110\u1165\u11ba"],["\ud144","\u1110\u1165\u11bb"],["\ud145","\u1110\u1165\u11bc"],["\ud146","\u1110\u1165\u11bd"],["\ud147","\u1110\u1165\u11be"],["\ud148","\u1110\u1165\u11bf"],["\ud149","\u1110\u1165\u11c0"],["\ud14a","\u1110\u1165\u11c1"],["\ud14b","\u1110\u1165\u11c2"],["\ud14c","\u1110\u1166"],["\ud14d","\u1110\u1166\u11a8"],["\ud14e","\u1110\u1166\u11a9"],["\ud14f","\u1110\u1166\u11aa"],["\ud150","\u1110\u1166\u11ab"],["\ud151","\u1110\u1166\u11ac"],["\ud152","\u1110\u1166\u11ad"],["\ud153","\u1110\u1166\u11ae"],["\ud154","\u1110\u1166\u11af"],["\ud155","\u1110\u1166\u11b0"],["\ud156","\u1110\u1166\u11b1"],["\ud157","\u1110\u1166\u11b2"],["\ud158","\u1110\u1166\u11b3"],["\ud159","\u1110\u1166\u11b4"],["\ud15a","\u1110\u1166\u11b5"],["\ud15b","\u1110\u1166\u11b6"],["\ud15c","\u1110\u1166\u11b7"],["\ud15d","\u1110\u1166\u11b8"],["\ud15e","\u1110\u1166\u11b9"],["\ud15f","\u1110\u1166\u11ba"],["\ud160","\u1110\u1166\u11bb"],["\ud161","\u1110\u1166\u11bc"],["\ud162","\u1110\u1166\u11bd"],["\ud163","\u1110\u1166\u11be"],["\ud164","\u1110\u1166\u11bf"],["\ud165","\u1110\u1166\u11c0"],["\ud166","\u1110\u1166\u11c1"],["\ud167","\u1110\u1166\u11c2"],["\ud168","\u1110\u1167"],["\ud169","\u1110\u1167\u11a8"],["\ud16a","\u1110\u1167\u11a9"],["\ud16b","\u1110\u1167\u11aa"],["\ud16c","\u1110\u1167\u11ab"],["\ud16d","\u1110\u1167\u11ac"],["\ud16e","\u1110\u1167\u11ad"],["\ud16f","\u1110\u1167\u11ae"],["\ud170","\u1110\u1167\u11af"],["\ud171","\u1110\u1167\u11b0"],["\ud172","\u1110\u1167\u11b1"],["\ud173","\u1110\u1167\u11b2"],["\ud174","\u1110\u1167\u11b3"],["\ud175","\u1110\u1167\u11b4"],["\ud176","\u1110\u1167\u11b5"],["\ud177","\u1110\u1167\u11b6"],["\ud178","\u1110\u1167\u11b7"],["\ud179","\u1110\u1167\u11b8"],["\ud17a","\u1110\u1167\u11b9"],["\ud17b","\u1110\u1167\u11ba"],["\ud17c","\u1110\u1167\u11bb"],["\ud17d","\u1110\u1167\u11bc"],["\ud17e","\u1110\u1167\u11bd"],["\ud17f","\u1110\u1167\u11be"],["\ud180","\u1110\u1167\u11bf"],["\ud181","\u1110\u1167\u11c0"],["\ud182","\u1110\u1167\u11c1"],["\ud183","\u1110\u1167\u11c2"],["\ud184","\u1110\u1168"],["\ud185","\u1110\u1168\u11a8"],["\ud186","\u1110\u1168\u11a9"],["\ud187","\u1110\u1168\u11aa"],["\ud188","\u1110\u1168\u11ab"],["\ud189","\u1110\u1168\u11ac"],["\ud18a","\u1110\u1168\u11ad"],["\ud18b","\u1110\u1168\u11ae"],["\ud18c","\u1110\u1168\u11af"],["\ud18d","\u1110\u1168\u11b0"],["\ud18e","\u1110\u1168\u11b1"],["\ud18f","\u1110\u1168\u11b2"],["\ud190","\u1110\u1168\u11b3"],["\ud191","\u1110\u1168\u11b4"],["\ud192","\u1110\u1168\u11b5"],["\ud193","\u1110\u1168\u11b6"],["\ud194","\u1110\u1168\u11b7"],["\ud195","\u1110\u1168\u11b8"],["\ud196","\u1110\u1168\u11b9"],["\ud197","\u1110\u1168\u11ba"],["\ud198","\u1110\u1168\u11bb"],["\ud199","\u1110\u1168\u11bc"],["\ud19a","\u1110\u1168\u11bd"],["\ud19b","\u1110\u1168\u11be"],["\ud19c","\u1110\u1168\u11bf"],["\ud19d","\u1110\u1168\u11c0"],["\ud19e","\u1110\u1168\u11c1"],["\ud19f","\u1110\u1168\u11c2"],["\ud1a0","\u1110\u1169"],["\ud1a1","\u1110\u1169\u11a8"],["\ud1a2","\u1110\u1169\u11a9"],["\ud1a3","\u1110\u1169\u11aa"],["\ud1a4","\u1110\u1169\u11ab"],["\ud1a5","\u1110\u1169\u11ac"],["\ud1a6","\u1110\u1169\u11ad"],["\ud1a7","\u1110\u1169\u11ae"],["\ud1a8","\u1110\u1169\u11af"],["\ud1a9","\u1110\u1169\u11b0"],["\ud1aa","\u1110\u1169\u11b1"],["\ud1ab","\u1110\u1169\u11b2"],["\ud1ac","\u1110\u1169\u11b3"],["\ud1ad","\u1110\u1169\u11b4"],["\ud1ae","\u1110\u1169\u11b5"],["\ud1af","\u1110\u1169\u11b6"],["\ud1b0","\u1110\u1169\u11b7"],["\ud1b1","\u1110\u1169\u11b8"],["\ud1b2","\u1110\u1169\u11b9"],["\ud1b3","\u1110\u1169\u11ba"],["\ud1b4","\u1110\u1169\u11bb"],["\ud1b5","\u1110\u1169\u11bc"],["\ud1b6","\u1110\u1169\u11bd"],["\ud1b7","\u1110\u1169\u11be"],["\ud1b8","\u1110\u1169\u11bf"],["\ud1b9","\u1110\u1169\u11c0"],["\ud1ba","\u1110\u1169\u11c1"],["\ud1bb","\u1110\u1169\u11c2"],["\ud1bc","\u1110\u116a"],["\ud1bd","\u1110\u116a\u11a8"],["\ud1be","\u1110\u116a\u11a9"],["\ud1bf","\u1110\u116a\u11aa"],["\ud1c0","\u1110\u116a\u11ab"],["\ud1c1","\u1110\u116a\u11ac"],["\ud1c2","\u1110\u116a\u11ad"],["\ud1c3","\u1110\u116a\u11ae"],["\ud1c4","\u1110\u116a\u11af"],["\ud1c5","\u1110\u116a\u11b0"],["\ud1c6","\u1110\u116a\u11b1"],["\ud1c7","\u1110\u116a\u11b2"],["\ud1c8","\u1110\u116a\u11b3"],["\ud1c9","\u1110\u116a\u11b4"],["\ud1ca","\u1110\u116a\u11b5"],["\ud1cb","\u1110\u116a\u11b6"],["\ud1cc","\u1110\u116a\u11b7"],["\ud1cd","\u1110\u116a\u11b8"],["\ud1ce","\u1110\u116a\u11b9"],["\ud1cf","\u1110\u116a\u11ba"],["\ud1d0","\u1110\u116a\u11bb"],["\ud1d1","\u1110\u116a\u11bc"],["\ud1d2","\u1110\u116a\u11bd"],["\ud1d3","\u1110\u116a\u11be"],["\ud1d4","\u1110\u116a\u11bf"],["\ud1d5","\u1110\u116a\u11c0"],["\ud1d6","\u1110\u116a\u11c1"],["\ud1d7","\u1110\u116a\u11c2"],["\ud1d8","\u1110\u116b"],["\ud1d9","\u1110\u116b\u11a8"],["\ud1da","\u1110\u116b\u11a9"],["\ud1db","\u1110\u116b\u11aa"],["\ud1dc","\u1110\u116b\u11ab"],["\ud1dd","\u1110\u116b\u11ac"],["\ud1de","\u1110\u116b\u11ad"],["\ud1df","\u1110\u116b\u11ae"],["\ud1e0","\u1110\u116b\u11af"],["\ud1e1","\u1110\u116b\u11b0"],["\ud1e2","\u1110\u116b\u11b1"],["\ud1e3","\u1110\u116b\u11b2"],["\ud1e4","\u1110\u116b\u11b3"],["\ud1e5","\u1110\u116b\u11b4"],["\ud1e6","\u1110\u116b\u11b5"],["\ud1e7","\u1110\u116b\u11b6"],["\ud1e8","\u1110\u116b\u11b7"],["\ud1e9","\u1110\u116b\u11b8"],["\ud1ea","\u1110\u116b\u11b9"],["\ud1eb","\u1110\u116b\u11ba"],["\ud1ec","\u1110\u116b\u11bb"],["\ud1ed","\u1110\u116b\u11bc"],["\ud1ee","\u1110\u116b\u11bd"],["\ud1ef","\u1110\u116b\u11be"],["\ud1f0","\u1110\u116b\u11bf"],["\ud1f1","\u1110\u116b\u11c0"],["\ud1f2","\u1110\u116b\u11c1"],["\ud1f3","\u1110\u116b\u11c2"],["\ud1f4","\u1110\u116c"],["\ud1f5","\u1110\u116c\u11a8"],["\ud1f6","\u1110\u116c\u11a9"],["\ud1f7","\u1110\u116c\u11aa"],["\ud1f8","\u1110\u116c\u11ab"],["\ud1f9","\u1110\u116c\u11ac"],["\ud1fa","\u1110\u116c\u11ad"],["\ud1fb","\u1110\u116c\u11ae"],["\ud1fc","\u1110\u116c\u11af"],["\ud1fd","\u1110\u116c\u11b0"],["\ud1fe","\u1110\u116c\u11b1"],["\ud1ff","\u1110\u116c\u11b2"],["\ud200","\u1110\u116c\u11b3"],["\ud201","\u1110\u116c\u11b4"],["\ud202","\u1110\u116c\u11b5"],["\ud203","\u1110\u116c\u11b6"],["\ud204","\u1110\u116c\u11b7"],["\ud205","\u1110\u116c\u11b8"],["\ud206","\u1110\u116c\u11b9"],["\ud207","\u1110\u116c\u11ba"],["\ud208","\u1110\u116c\u11bb"],["\ud209","\u1110\u116c\u11bc"],["\ud20a","\u1110\u116c\u11bd"],["\ud20b","\u1110\u116c\u11be"],["\ud20c","\u1110\u116c\u11bf"],["\ud20d","\u1110\u116c\u11c0"],["\ud20e","\u1110\u116c\u11c1"],["\ud20f","\u1110\u116c\u11c2"],["\ud210","\u1110\u116d"],["\ud211","\u1110\u116d\u11a8"],["\ud212","\u1110\u116d\u11a9"],["\ud213","\u1110\u116d\u11aa"],["\ud214","\u1110\u116d\u11ab"],["\ud215","\u1110\u116d\u11ac"],["\ud216","\u1110\u116d\u11ad"],["\ud217","\u1110\u116d\u11ae"],["\ud218","\u1110\u116d\u11af"],["\ud219","\u1110\u116d\u11b0"],["\ud21a","\u1110\u116d\u11b1"],["\ud21b","\u1110\u116d\u11b2"],["\ud21c","\u1110\u116d\u11b3"],["\ud21d","\u1110\u116d\u11b4"],["\ud21e","\u1110\u116d\u11b5"],["\ud21f","\u1110\u116d\u11b6"],["\ud220","\u1110\u116d\u11b7"],["\ud221","\u1110\u116d\u11b8"],["\ud222","\u1110\u116d\u11b9"],["\ud223","\u1110\u116d\u11ba"],["\ud224","\u1110\u116d\u11bb"],["\ud225","\u1110\u116d\u11bc"],["\ud226","\u1110\u116d\u11bd"],["\ud227","\u1110\u116d\u11be"],["\ud228","\u1110\u116d\u11bf"],["\ud229","\u1110\u116d\u11c0"],["\ud22a","\u1110\u116d\u11c1"],["\ud22b","\u1110\u116d\u11c2"],["\ud22c","\u1110\u116e"],["\ud22d","\u1110\u116e\u11a8"],["\ud22e","\u1110\u116e\u11a9"],["\ud22f","\u1110\u116e\u11aa"],["\ud230","\u1110\u116e\u11ab"],["\ud231","\u1110\u116e\u11ac"],["\ud232","\u1110\u116e\u11ad"],["\ud233","\u1110\u116e\u11ae"],["\ud234","\u1110\u116e\u11af"],["\ud235","\u1110\u116e\u11b0"],["\ud236","\u1110\u116e\u11b1"],["\ud237","\u1110\u116e\u11b2"],["\ud238","\u1110\u116e\u11b3"],["\ud239","\u1110\u116e\u11b4"],["\ud23a","\u1110\u116e\u11b5"],["\ud23b","\u1110\u116e\u11b6"],["\ud23c","\u1110\u116e\u11b7"],["\ud23d","\u1110\u116e\u11b8"],["\ud23e","\u1110\u116e\u11b9"],["\ud23f","\u1110\u116e\u11ba"],["\ud240","\u1110\u116e\u11bb"],["\ud241","\u1110\u116e\u11bc"],["\ud242","\u1110\u116e\u11bd"],["\ud243","\u1110\u116e\u11be"],["\ud244","\u1110\u116e\u11bf"],["\ud245","\u1110\u116e\u11c0"],["\ud246","\u1110\u116e\u11c1"],["\ud247","\u1110\u116e\u11c2"],["\ud248","\u1110\u116f"],["\ud249","\u1110\u116f\u11a8"],["\ud24a","\u1110\u116f\u11a9"],["\ud24b","\u1110\u116f\u11aa"],["\ud24c","\u1110\u116f\u11ab"],["\ud24d","\u1110\u116f\u11ac"],["\ud24e","\u1110\u116f\u11ad"],["\ud24f","\u1110\u116f\u11ae"],["\ud250","\u1110\u116f\u11af"],["\ud251","\u1110\u116f\u11b0"],["\ud252","\u1110\u116f\u11b1"],["\ud253","\u1110\u116f\u11b2"],["\ud254","\u1110\u116f\u11b3"],["\ud255","\u1110\u116f\u11b4"],["\ud256","\u1110\u116f\u11b5"],["\ud257","\u1110\u116f\u11b6"],["\ud258","\u1110\u116f\u11b7"],["\ud259","\u1110\u116f\u11b8"],["\ud25a","\u1110\u116f\u11b9"],["\ud25b","\u1110\u116f\u11ba"],["\ud25c","\u1110\u116f\u11bb"],["\ud25d","\u1110\u116f\u11bc"],["\ud25e","\u1110\u116f\u11bd"],["\ud25f","\u1110\u116f\u11be"],["\ud260","\u1110\u116f\u11bf"],["\ud261","\u1110\u116f\u11c0"],["\ud262","\u1110\u116f\u11c1"],["\ud263","\u1110\u116f\u11c2"],["\ud264","\u1110\u1170"],["\ud265","\u1110\u1170\u11a8"],["\ud266","\u1110\u1170\u11a9"],["\ud267","\u1110\u1170\u11aa"],["\ud268","\u1110\u1170\u11ab"],["\ud269","\u1110\u1170\u11ac"],["\ud26a","\u1110\u1170\u11ad"],["\ud26b","\u1110\u1170\u11ae"],["\ud26c","\u1110\u1170\u11af"],["\ud26d","\u1110\u1170\u11b0"],["\ud26e","\u1110\u1170\u11b1"],["\ud26f","\u1110\u1170\u11b2"],["\ud270","\u1110\u1170\u11b3"],["\ud271","\u1110\u1170\u11b4"],["\ud272","\u1110\u1170\u11b5"],["\ud273","\u1110\u1170\u11b6"],["\ud274","\u1110\u1170\u11b7"],["\ud275","\u1110\u1170\u11b8"],["\ud276","\u1110\u1170\u11b9"],["\ud277","\u1110\u1170\u11ba"],["\ud278","\u1110\u1170\u11bb"],["\ud279","\u1110\u1170\u11bc"],["\ud27a","\u1110\u1170\u11bd"],["\ud27b","\u1110\u1170\u11be"],["\ud27c","\u1110\u1170\u11bf"],["\ud27d","\u1110\u1170\u11c0"],["\ud27e","\u1110\u1170\u11c1"],["\ud27f","\u1110\u1170\u11c2"],["\ud280","\u1110\u1171"],["\ud281","\u1110\u1171\u11a8"],["\ud282","\u1110\u1171\u11a9"],["\ud283","\u1110\u1171\u11aa"],["\ud284","\u1110\u1171\u11ab"],["\ud285","\u1110\u1171\u11ac"],["\ud286","\u1110\u1171\u11ad"],["\ud287","\u1110\u1171\u11ae"],["\ud288","\u1110\u1171\u11af"],["\ud289","\u1110\u1171\u11b0"],["\ud28a","\u1110\u1171\u11b1"],["\ud28b","\u1110\u1171\u11b2"],["\ud28c","\u1110\u1171\u11b3"],["\ud28d","\u1110\u1171\u11b4"],["\ud28e","\u1110\u1171\u11b5"],["\ud28f","\u1110\u1171\u11b6"],["\ud290","\u1110\u1171\u11b7"],["\ud291","\u1110\u1171\u11b8"],["\ud292","\u1110\u1171\u11b9"],["\ud293","\u1110\u1171\u11ba"],["\ud294","\u1110\u1171\u11bb"],["\ud295","\u1110\u1171\u11bc"],["\ud296","\u1110\u1171\u11bd"],["\ud297","\u1110\u1171\u11be"],["\ud298","\u1110\u1171\u11bf"],["\ud299","\u1110\u1171\u11c0"],["\ud29a","\u1110\u1171\u11c1"],["\ud29b","\u1110\u1171\u11c2"],["\ud29c","\u1110\u1172"],["\ud29d","\u1110\u1172\u11a8"],["\ud29e","\u1110\u1172\u11a9"],["\ud29f","\u1110\u1172\u11aa"],["\ud2a0","\u1110\u1172\u11ab"],["\ud2a1","\u1110\u1172\u11ac"],["\ud2a2","\u1110\u1172\u11ad"],["\ud2a3","\u1110\u1172\u11ae"],["\ud2a4","\u1110\u1172\u11af"],["\ud2a5","\u1110\u1172\u11b0"],["\ud2a6","\u1110\u1172\u11b1"],["\ud2a7","\u1110\u1172\u11b2"],["\ud2a8","\u1110\u1172\u11b3"],["\ud2a9","\u1110\u1172\u11b4"],["\ud2aa","\u1110\u1172\u11b5"],["\ud2ab","\u1110\u1172\u11b6"],["\ud2ac","\u1110\u1172\u11b7"],["\ud2ad","\u1110\u1172\u11b8"],["\ud2ae","\u1110\u1172\u11b9"],["\ud2af","\u1110\u1172\u11ba"],["\ud2b0","\u1110\u1172\u11bb"],["\ud2b1","\u1110\u1172\u11bc"],["\ud2b2","\u1110\u1172\u11bd"],["\ud2b3","\u1110\u1172\u11be"],["\ud2b4","\u1110\u1172\u11bf"],["\ud2b5","\u1110\u1172\u11c0"],["\ud2b6","\u1110\u1172\u11c1"],["\ud2b7","\u1110\u1172\u11c2"],["\ud2b8","\u1110\u1173"],["\ud2b9","\u1110\u1173\u11a8"],["\ud2ba","\u1110\u1173\u11a9"],["\ud2bb","\u1110\u1173\u11aa"],["\ud2bc","\u1110\u1173\u11ab"],["\ud2bd","\u1110\u1173\u11ac"],["\ud2be","\u1110\u1173\u11ad"],["\ud2bf","\u1110\u1173\u11ae"],["\ud2c0","\u1110\u1173\u11af"],["\ud2c1","\u1110\u1173\u11b0"],["\ud2c2","\u1110\u1173\u11b1"],["\ud2c3","\u1110\u1173\u11b2"],["\ud2c4","\u1110\u1173\u11b3"],["\ud2c5","\u1110\u1173\u11b4"],["\ud2c6","\u1110\u1173\u11b5"],["\ud2c7","\u1110\u1173\u11b6"],["\ud2c8","\u1110\u1173\u11b7"],["\ud2c9","\u1110\u1173\u11b8"],["\ud2ca","\u1110\u1173\u11b9"],["\ud2cb","\u1110\u1173\u11ba"],["\ud2cc","\u1110\u1173\u11bb"],["\ud2cd","\u1110\u1173\u11bc"],["\ud2ce","\u1110\u1173\u11bd"],["\ud2cf","\u1110\u1173\u11be"],["\ud2d0","\u1110\u1173\u11bf"],["\ud2d1","\u1110\u1173\u11c0"],["\ud2d2","\u1110\u1173\u11c1"],["\ud2d3","\u1110\u1173\u11c2"],["\ud2d4","\u1110\u1174"],["\ud2d5","\u1110\u1174\u11a8"],["\ud2d6","\u1110\u1174\u11a9"],["\ud2d7","\u1110\u1174\u11aa"],["\ud2d8","\u1110\u1174\u11ab"],["\ud2d9","\u1110\u1174\u11ac"],["\ud2da","\u1110\u1174\u11ad"],["\ud2db","\u1110\u1174\u11ae"],["\ud2dc","\u1110\u1174\u11af"],["\ud2dd","\u1110\u1174\u11b0"],["\ud2de","\u1110\u1174\u11b1"],["\ud2df","\u1110\u1174\u11b2"],["\ud2e0","\u1110\u1174\u11b3"],["\ud2e1","\u1110\u1174\u11b4"],["\ud2e2","\u1110\u1174\u11b5"],["\ud2e3","\u1110\u1174\u11b6"],["\ud2e4","\u1110\u1174\u11b7"],["\ud2e5","\u1110\u1174\u11b8"],["\ud2e6","\u1110\u1174\u11b9"],["\ud2e7","\u1110\u1174\u11ba"],["\ud2e8","\u1110\u1174\u11bb"],["\ud2e9","\u1110\u1174\u11bc"],["\ud2ea","\u1110\u1174\u11bd"],["\ud2eb","\u1110\u1174\u11be"],["\ud2ec","\u1110\u1174\u11bf"],["\ud2ed","\u1110\u1174\u11c0"],["\ud2ee","\u1110\u1174\u11c1"],["\ud2ef","\u1110\u1174\u11c2"],["\ud2f0","\u1110\u1175"],["\ud2f1","\u1110\u1175\u11a8"],["\ud2f2","\u1110\u1175\u11a9"],["\ud2f3","\u1110\u1175\u11aa"],["\ud2f4","\u1110\u1175\u11ab"],["\ud2f5","\u1110\u1175\u11ac"],["\ud2f6","\u1110\u1175\u11ad"],["\ud2f7","\u1110\u1175\u11ae"],["\ud2f8","\u1110\u1175\u11af"],["\ud2f9","\u1110\u1175\u11b0"],["\ud2fa","\u1110\u1175\u11b1"],["\ud2fb","\u1110\u1175\u11b2"],["\ud2fc","\u1110\u1175\u11b3"],["\ud2fd","\u1110\u1175\u11b4"],["\ud2fe","\u1110\u1175\u11b5"],["\ud2ff","\u1110\u1175\u11b6"],["\ud300","\u1110\u1175\u11b7"],["\ud301","\u1110\u1175\u11b8"],["\ud302","\u1110\u1175\u11b9"],["\ud303","\u1110\u1175\u11ba"],["\ud304","\u1110\u1175\u11bb"],["\ud305","\u1110\u1175\u11bc"],["\ud306","\u1110\u1175\u11bd"],["\ud307","\u1110\u1175\u11be"],["\ud308","\u1110\u1175\u11bf"],["\ud309","\u1110\u1175\u11c0"],["\ud30a","\u1110\u1175\u11c1"],["\ud30b","\u1110\u1175\u11c2"],["\ud30c","\u1111\u1161"],["\ud30d","\u1111\u1161\u11a8"],["\ud30e","\u1111\u1161\u11a9"],["\ud30f","\u1111\u1161\u11aa"],["\ud310","\u1111\u1161\u11ab"],["\ud311","\u1111\u1161\u11ac"],["\ud312","\u1111\u1161\u11ad"],["\ud313","\u1111\u1161\u11ae"],["\ud314","\u1111\u1161\u11af"],["\ud315","\u1111\u1161\u11b0"],["\ud316","\u1111\u1161\u11b1"],["\ud317","\u1111\u1161\u11b2"],["\ud318","\u1111\u1161\u11b3"],["\ud319","\u1111\u1161\u11b4"],["\ud31a","\u1111\u1161\u11b5"],["\ud31b","\u1111\u1161\u11b6"],["\ud31c","\u1111\u1161\u11b7"],["\ud31d","\u1111\u1161\u11b8"],["\ud31e","\u1111\u1161\u11b9"],["\ud31f","\u1111\u1161\u11ba"],["\ud320","\u1111\u1161\u11bb"],["\ud321","\u1111\u1161\u11bc"],["\ud322","\u1111\u1161\u11bd"],["\ud323","\u1111\u1161\u11be"],["\ud324","\u1111\u1161\u11bf"],["\ud325","\u1111\u1161\u11c0"],["\ud326","\u1111\u1161\u11c1"],["\ud327","\u1111\u1161\u11c2"],["\ud328","\u1111\u1162"],["\ud329","\u1111\u1162\u11a8"],["\ud32a","\u1111\u1162\u11a9"],["\ud32b","\u1111\u1162\u11aa"],["\ud32c","\u1111\u1162\u11ab"],["\ud32d","\u1111\u1162\u11ac"],["\ud32e","\u1111\u1162\u11ad"],["\ud32f","\u1111\u1162\u11ae"],["\ud330","\u1111\u1162\u11af"],["\ud331","\u1111\u1162\u11b0"],["\ud332","\u1111\u1162\u11b1"],["\ud333","\u1111\u1162\u11b2"],["\ud334","\u1111\u1162\u11b3"],["\ud335","\u1111\u1162\u11b4"],["\ud336","\u1111\u1162\u11b5"],["\ud337","\u1111\u1162\u11b6"],["\ud338","\u1111\u1162\u11b7"],["\ud339","\u1111\u1162\u11b8"],["\ud33a","\u1111\u1162\u11b9"],["\ud33b","\u1111\u1162\u11ba"],["\ud33c","\u1111\u1162\u11bb"],["\ud33d","\u1111\u1162\u11bc"],["\ud33e","\u1111\u1162\u11bd"],["\ud33f","\u1111\u1162\u11be"],["\ud340","\u1111\u1162\u11bf"],["\ud341","\u1111\u1162\u11c0"],["\ud342","\u1111\u1162\u11c1"],["\ud343","\u1111\u1162\u11c2"],["\ud344","\u1111\u1163"],["\ud345","\u1111\u1163\u11a8"],["\ud346","\u1111\u1163\u11a9"],["\ud347","\u1111\u1163\u11aa"],["\ud348","\u1111\u1163\u11ab"],["\ud349","\u1111\u1163\u11ac"],["\ud34a","\u1111\u1163\u11ad"],["\ud34b","\u1111\u1163\u11ae"],["\ud34c","\u1111\u1163\u11af"],["\ud34d","\u1111\u1163\u11b0"],["\ud34e","\u1111\u1163\u11b1"],["\ud34f","\u1111\u1163\u11b2"],["\ud350","\u1111\u1163\u11b3"],["\ud351","\u1111\u1163\u11b4"],["\ud352","\u1111\u1163\u11b5"],["\ud353","\u1111\u1163\u11b6"],["\ud354","\u1111\u1163\u11b7"],["\ud355","\u1111\u1163\u11b8"],["\ud356","\u1111\u1163\u11b9"],["\ud357","\u1111\u1163\u11ba"],["\ud358","\u1111\u1163\u11bb"],["\ud359","\u1111\u1163\u11bc"],["\ud35a","\u1111\u1163\u11bd"],["\ud35b","\u1111\u1163\u11be"],["\ud35c","\u1111\u1163\u11bf"],["\ud35d","\u1111\u1163\u11c0"],["\ud35e","\u1111\u1163\u11c1"],["\ud35f","\u1111\u1163\u11c2"],["\ud360","\u1111\u1164"],["\ud361","\u1111\u1164\u11a8"],["\ud362","\u1111\u1164\u11a9"],["\ud363","\u1111\u1164\u11aa"],["\ud364","\u1111\u1164\u11ab"],["\ud365","\u1111\u1164\u11ac"],["\ud366","\u1111\u1164\u11ad"],["\ud367","\u1111\u1164\u11ae"],["\ud368","\u1111\u1164\u11af"],["\ud369","\u1111\u1164\u11b0"],["\ud36a","\u1111\u1164\u11b1"],["\ud36b","\u1111\u1164\u11b2"],["\ud36c","\u1111\u1164\u11b3"],["\ud36d","\u1111\u1164\u11b4"],["\ud36e","\u1111\u1164\u11b5"],["\ud36f","\u1111\u1164\u11b6"],["\ud370","\u1111\u1164\u11b7"],["\ud371","\u1111\u1164\u11b8"],["\ud372","\u1111\u1164\u11b9"],["\ud373","\u1111\u1164\u11ba"],["\ud374","\u1111\u1164\u11bb"],["\ud375","\u1111\u1164\u11bc"],["\ud376","\u1111\u1164\u11bd"],["\ud377","\u1111\u1164\u11be"],["\ud378","\u1111\u1164\u11bf"],["\ud379","\u1111\u1164\u11c0"],["\ud37a","\u1111\u1164\u11c1"],["\ud37b","\u1111\u1164\u11c2"],["\ud37c","\u1111\u1165"],["\ud37d","\u1111\u1165\u11a8"],["\ud37e","\u1111\u1165\u11a9"],["\ud37f","\u1111\u1165\u11aa"],["\ud380","\u1111\u1165\u11ab"],["\ud381","\u1111\u1165\u11ac"],["\ud382","\u1111\u1165\u11ad"],["\ud383","\u1111\u1165\u11ae"],["\ud384","\u1111\u1165\u11af"],["\ud385","\u1111\u1165\u11b0"],["\ud386","\u1111\u1165\u11b1"],["\ud387","\u1111\u1165\u11b2"],["\ud388","\u1111\u1165\u11b3"],["\ud389","\u1111\u1165\u11b4"],["\ud38a","\u1111\u1165\u11b5"],["\ud38b","\u1111\u1165\u11b6"],["\ud38c","\u1111\u1165\u11b7"],["\ud38d","\u1111\u1165\u11b8"],["\ud38e","\u1111\u1165\u11b9"],["\ud38f","\u1111\u1165\u11ba"],["\ud390","\u1111\u1165\u11bb"],["\ud391","\u1111\u1165\u11bc"],["\ud392","\u1111\u1165\u11bd"],["\ud393","\u1111\u1165\u11be"],["\ud394","\u1111\u1165\u11bf"],["\ud395","\u1111\u1165\u11c0"],["\ud396","\u1111\u1165\u11c1"],["\ud397","\u1111\u1165\u11c2"],["\ud398","\u1111\u1166"],["\ud399","\u1111\u1166\u11a8"],["\ud39a","\u1111\u1166\u11a9"],["\ud39b","\u1111\u1166\u11aa"],["\ud39c","\u1111\u1166\u11ab"],["\ud39d","\u1111\u1166\u11ac"],["\ud39e","\u1111\u1166\u11ad"],["\ud39f","\u1111\u1166\u11ae"],["\ud3a0","\u1111\u1166\u11af"],["\ud3a1","\u1111\u1166\u11b0"],["\ud3a2","\u1111\u1166\u11b1"],["\ud3a3","\u1111\u1166\u11b2"],["\ud3a4","\u1111\u1166\u11b3"],["\ud3a5","\u1111\u1166\u11b4"],["\ud3a6","\u1111\u1166\u11b5"],["\ud3a7","\u1111\u1166\u11b6"],["\ud3a8","\u1111\u1166\u11b7"],["\ud3a9","\u1111\u1166\u11b8"],["\ud3aa","\u1111\u1166\u11b9"],["\ud3ab","\u1111\u1166\u11ba"],["\ud3ac","\u1111\u1166\u11bb"],["\ud3ad","\u1111\u1166\u11bc"],["\ud3ae","\u1111\u1166\u11bd"],["\ud3af","\u1111\u1166\u11be"],["\ud3b0","\u1111\u1166\u11bf"],["\ud3b1","\u1111\u1166\u11c0"],["\ud3b2","\u1111\u1166\u11c1"],["\ud3b3","\u1111\u1166\u11c2"],["\ud3b4","\u1111\u1167"],["\ud3b5","\u1111\u1167\u11a8"],["\ud3b6","\u1111\u1167\u11a9"],["\ud3b7","\u1111\u1167\u11aa"],["\ud3b8","\u1111\u1167\u11ab"],["\ud3b9","\u1111\u1167\u11ac"],["\ud3ba","\u1111\u1167\u11ad"],["\ud3bb","\u1111\u1167\u11ae"],["\ud3bc","\u1111\u1167\u11af"],["\ud3bd","\u1111\u1167\u11b0"],["\ud3be","\u1111\u1167\u11b1"],["\ud3bf","\u1111\u1167\u11b2"],["\ud3c0","\u1111\u1167\u11b3"],["\ud3c1","\u1111\u1167\u11b4"],["\ud3c2","\u1111\u1167\u11b5"],["\ud3c3","\u1111\u1167\u11b6"],["\ud3c4","\u1111\u1167\u11b7"],["\ud3c5","\u1111\u1167\u11b8"],["\ud3c6","\u1111\u1167\u11b9"],["\ud3c7","\u1111\u1167\u11ba"],["\ud3c8","\u1111\u1167\u11bb"],["\ud3c9","\u1111\u1167\u11bc"],["\ud3ca","\u1111\u1167\u11bd"],["\ud3cb","\u1111\u1167\u11be"],["\ud3cc","\u1111\u1167\u11bf"],["\ud3cd","\u1111\u1167\u11c0"],["\ud3ce","\u1111\u1167\u11c1"],["\ud3cf","\u1111\u1167\u11c2"],["\ud3d0","\u1111\u1168"],["\ud3d1","\u1111\u1168\u11a8"],["\ud3d2","\u1111\u1168\u11a9"],["\ud3d3","\u1111\u1168\u11aa"],["\ud3d4","\u1111\u1168\u11ab"],["\ud3d5","\u1111\u1168\u11ac"],["\ud3d6","\u1111\u1168\u11ad"],["\ud3d7","\u1111\u1168\u11ae"],["\ud3d8","\u1111\u1168\u11af"],["\ud3d9","\u1111\u1168\u11b0"],["\ud3da","\u1111\u1168\u11b1"],["\ud3db","\u1111\u1168\u11b2"],["\ud3dc","\u1111\u1168\u11b3"],["\ud3dd","\u1111\u1168\u11b4"],["\ud3de","\u1111\u1168\u11b5"],["\ud3df","\u1111\u1168\u11b6"],["\ud3e0","\u1111\u1168\u11b7"],["\ud3e1","\u1111\u1168\u11b8"],["\ud3e2","\u1111\u1168\u11b9"],["\ud3e3","\u1111\u1168\u11ba"],["\ud3e4","\u1111\u1168\u11bb"],["\ud3e5","\u1111\u1168\u11bc"],["\ud3e6","\u1111\u1168\u11bd"],["\ud3e7","\u1111\u1168\u11be"],["\ud3e8","\u1111\u1168\u11bf"],["\ud3e9","\u1111\u1168\u11c0"],["\ud3ea","\u1111\u1168\u11c1"],["\ud3eb","\u1111\u1168\u11c2"],["\ud3ec","\u1111\u1169"],["\ud3ed","\u1111\u1169\u11a8"],["\ud3ee","\u1111\u1169\u11a9"],["\ud3ef","\u1111\u1169\u11aa"],["\ud3f0","\u1111\u1169\u11ab"],["\ud3f1","\u1111\u1169\u11ac"],["\ud3f2","\u1111\u1169\u11ad"],["\ud3f3","\u1111\u1169\u11ae"],["\ud3f4","\u1111\u1169\u11af"],["\ud3f5","\u1111\u1169\u11b0"],["\ud3f6","\u1111\u1169\u11b1"],["\ud3f7","\u1111\u1169\u11b2"],["\ud3f8","\u1111\u1169\u11b3"],["\ud3f9","\u1111\u1169\u11b4"],["\ud3fa","\u1111\u1169\u11b5"],["\ud3fb","\u1111\u1169\u11b6"],["\ud3fc","\u1111\u1169\u11b7"],["\ud3fd","\u1111\u1169\u11b8"],["\ud3fe","\u1111\u1169\u11b9"],["\ud3ff","\u1111\u1169\u11ba"],["\ud400","\u1111\u1169\u11bb"],["\ud401","\u1111\u1169\u11bc"],["\ud402","\u1111\u1169\u11bd"],["\ud403","\u1111\u1169\u11be"],["\ud404","\u1111\u1169\u11bf"],["\ud405","\u1111\u1169\u11c0"],["\ud406","\u1111\u1169\u11c1"],["\ud407","\u1111\u1169\u11c2"],["\ud408","\u1111\u116a"],["\ud409","\u1111\u116a\u11a8"],["\ud40a","\u1111\u116a\u11a9"],["\ud40b","\u1111\u116a\u11aa"],["\ud40c","\u1111\u116a\u11ab"],["\ud40d","\u1111\u116a\u11ac"],["\ud40e","\u1111\u116a\u11ad"],["\ud40f","\u1111\u116a\u11ae"],["\ud410","\u1111\u116a\u11af"],["\ud411","\u1111\u116a\u11b0"],["\ud412","\u1111\u116a\u11b1"],["\ud413","\u1111\u116a\u11b2"],["\ud414","\u1111\u116a\u11b3"],["\ud415","\u1111\u116a\u11b4"],["\ud416","\u1111\u116a\u11b5"],["\ud417","\u1111\u116a\u11b6"],["\ud418","\u1111\u116a\u11b7"],["\ud419","\u1111\u116a\u11b8"],["\ud41a","\u1111\u116a\u11b9"],["\ud41b","\u1111\u116a\u11ba"],["\ud41c","\u1111\u116a\u11bb"],["\ud41d","\u1111\u116a\u11bc"],["\ud41e","\u1111\u116a\u11bd"],["\ud41f","\u1111\u116a\u11be"],["\ud420","\u1111\u116a\u11bf"],["\ud421","\u1111\u116a\u11c0"],["\ud422","\u1111\u116a\u11c1"],["\ud423","\u1111\u116a\u11c2"],["\ud424","\u1111\u116b"],["\ud425","\u1111\u116b\u11a8"],["\ud426","\u1111\u116b\u11a9"],["\ud427","\u1111\u116b\u11aa"],["\ud428","\u1111\u116b\u11ab"],["\ud429","\u1111\u116b\u11ac"],["\ud42a","\u1111\u116b\u11ad"],["\ud42b","\u1111\u116b\u11ae"],["\ud42c","\u1111\u116b\u11af"],["\ud42d","\u1111\u116b\u11b0"],["\ud42e","\u1111\u116b\u11b1"],["\ud42f","\u1111\u116b\u11b2"],["\ud430","\u1111\u116b\u11b3"],["\ud431","\u1111\u116b\u11b4"],["\ud432","\u1111\u116b\u11b5"],["\ud433","\u1111\u116b\u11b6"],["\ud434","\u1111\u116b\u11b7"],["\ud435","\u1111\u116b\u11b8"],["\ud436","\u1111\u116b\u11b9"],["\ud437","\u1111\u116b\u11ba"],["\ud438","\u1111\u116b\u11bb"],["\ud439","\u1111\u116b\u11bc"],["\ud43a","\u1111\u116b\u11bd"],["\ud43b","\u1111\u116b\u11be"],["\ud43c","\u1111\u116b\u11bf"],["\ud43d","\u1111\u116b\u11c0"],["\ud43e","\u1111\u116b\u11c1"],["\ud43f","\u1111\u116b\u11c2"],["\ud440","\u1111\u116c"],["\ud441","\u1111\u116c\u11a8"],["\ud442","\u1111\u116c\u11a9"],["\ud443","\u1111\u116c\u11aa"],["\ud444","\u1111\u116c\u11ab"],["\ud445","\u1111\u116c\u11ac"],["\ud446","\u1111\u116c\u11ad"],["\ud447","\u1111\u116c\u11ae"],["\ud448","\u1111\u116c\u11af"],["\ud449","\u1111\u116c\u11b0"],["\ud44a","\u1111\u116c\u11b1"],["\ud44b","\u1111\u116c\u11b2"],["\ud44c","\u1111\u116c\u11b3"],["\ud44d","\u1111\u116c\u11b4"],["\ud44e","\u1111\u116c\u11b5"],["\ud44f","\u1111\u116c\u11b6"],["\ud450","\u1111\u116c\u11b7"],["\ud451","\u1111\u116c\u11b8"],["\ud452","\u1111\u116c\u11b9"],["\ud453","\u1111\u116c\u11ba"],["\ud454","\u1111\u116c\u11bb"],["\ud455","\u1111\u116c\u11bc"],["\ud456","\u1111\u116c\u11bd"],["\ud457","\u1111\u116c\u11be"],["\ud458","\u1111\u116c\u11bf"],["\ud459","\u1111\u116c\u11c0"],["\ud45a","\u1111\u116c\u11c1"],["\ud45b","\u1111\u116c\u11c2"],["\ud45c","\u1111\u116d"],["\ud45d","\u1111\u116d\u11a8"],["\ud45e","\u1111\u116d\u11a9"],["\ud45f","\u1111\u116d\u11aa"],["\ud460","\u1111\u116d\u11ab"],["\ud461","\u1111\u116d\u11ac"],["\ud462","\u1111\u116d\u11ad"],["\ud463","\u1111\u116d\u11ae"],["\ud464","\u1111\u116d\u11af"],["\ud465","\u1111\u116d\u11b0"],["\ud466","\u1111\u116d\u11b1"],["\ud467","\u1111\u116d\u11b2"],["\ud468","\u1111\u116d\u11b3"],["\ud469","\u1111\u116d\u11b4"],["\ud46a","\u1111\u116d\u11b5"],["\ud46b","\u1111\u116d\u11b6"],["\ud46c","\u1111\u116d\u11b7"],["\ud46d","\u1111\u116d\u11b8"],["\ud46e","\u1111\u116d\u11b9"],["\ud46f","\u1111\u116d\u11ba"],["\ud470","\u1111\u116d\u11bb"],["\ud471","\u1111\u116d\u11bc"],["\ud472","\u1111\u116d\u11bd"],["\ud473","\u1111\u116d\u11be"],["\ud474","\u1111\u116d\u11bf"],["\ud475","\u1111\u116d\u11c0"],["\ud476","\u1111\u116d\u11c1"],["\ud477","\u1111\u116d\u11c2"],["\ud478","\u1111\u116e"],["\ud479","\u1111\u116e\u11a8"],["\ud47a","\u1111\u116e\u11a9"],["\ud47b","\u1111\u116e\u11aa"],["\ud47c","\u1111\u116e\u11ab"],["\ud47d","\u1111\u116e\u11ac"],["\ud47e","\u1111\u116e\u11ad"],["\ud47f","\u1111\u116e\u11ae"],["\ud480","\u1111\u116e\u11af"],["\ud481","\u1111\u116e\u11b0"],["\ud482","\u1111\u116e\u11b1"],["\ud483","\u1111\u116e\u11b2"],["\ud484","\u1111\u116e\u11b3"],["\ud485","\u1111\u116e\u11b4"],["\ud486","\u1111\u116e\u11b5"],["\ud487","\u1111\u116e\u11b6"],["\ud488","\u1111\u116e\u11b7"],["\ud489","\u1111\u116e\u11b8"],["\ud48a","\u1111\u116e\u11b9"],["\ud48b","\u1111\u116e\u11ba"],["\ud48c","\u1111\u116e\u11bb"],["\ud48d","\u1111\u116e\u11bc"],["\ud48e","\u1111\u116e\u11bd"],["\ud48f","\u1111\u116e\u11be"],["\ud490","\u1111\u116e\u11bf"],["\ud491","\u1111\u116e\u11c0"],["\ud492","\u1111\u116e\u11c1"],["\ud493","\u1111\u116e\u11c2"],["\ud494","\u1111\u116f"],["\ud495","\u1111\u116f\u11a8"],["\ud496","\u1111\u116f\u11a9"],["\ud497","\u1111\u116f\u11aa"],["\ud498","\u1111\u116f\u11ab"],["\ud499","\u1111\u116f\u11ac"],["\ud49a","\u1111\u116f\u11ad"],["\ud49b","\u1111\u116f\u11ae"],["\ud49c","\u1111\u116f\u11af"],["\ud49d","\u1111\u116f\u11b0"],["\ud49e","\u1111\u116f\u11b1"],["\ud49f","\u1111\u116f\u11b2"],["\ud4a0","\u1111\u116f\u11b3"],["\ud4a1","\u1111\u116f\u11b4"],["\ud4a2","\u1111\u116f\u11b5"],["\ud4a3","\u1111\u116f\u11b6"],["\ud4a4","\u1111\u116f\u11b7"],["\ud4a5","\u1111\u116f\u11b8"],["\ud4a6","\u1111\u116f\u11b9"],["\ud4a7","\u1111\u116f\u11ba"],["\ud4a8","\u1111\u116f\u11bb"],["\ud4a9","\u1111\u116f\u11bc"],["\ud4aa","\u1111\u116f\u11bd"],["\ud4ab","\u1111\u116f\u11be"],["\ud4ac","\u1111\u116f\u11bf"],["\ud4ad","\u1111\u116f\u11c0"],["\ud4ae","\u1111\u116f\u11c1"],["\ud4af","\u1111\u116f\u11c2"],["\ud4b0","\u1111\u1170"],["\ud4b1","\u1111\u1170\u11a8"],["\ud4b2","\u1111\u1170\u11a9"],["\ud4b3","\u1111\u1170\u11aa"],["\ud4b4","\u1111\u1170\u11ab"],["\ud4b5","\u1111\u1170\u11ac"],["\ud4b6","\u1111\u1170\u11ad"],["\ud4b7","\u1111\u1170\u11ae"],["\ud4b8","\u1111\u1170\u11af"],["\ud4b9","\u1111\u1170\u11b0"],["\ud4ba","\u1111\u1170\u11b1"],["\ud4bb","\u1111\u1170\u11b2"],["\ud4bc","\u1111\u1170\u11b3"],["\ud4bd","\u1111\u1170\u11b4"],["\ud4be","\u1111\u1170\u11b5"],["\ud4bf","\u1111\u1170\u11b6"],["\ud4c0","\u1111\u1170\u11b7"],["\ud4c1","\u1111\u1170\u11b8"],["\ud4c2","\u1111\u1170\u11b9"],["\ud4c3","\u1111\u1170\u11ba"],["\ud4c4","\u1111\u1170\u11bb"],["\ud4c5","\u1111\u1170\u11bc"],["\ud4c6","\u1111\u1170\u11bd"],["\ud4c7","\u1111\u1170\u11be"],["\ud4c8","\u1111\u1170\u11bf"],["\ud4c9","\u1111\u1170\u11c0"],["\ud4ca","\u1111\u1170\u11c1"],["\ud4cb","\u1111\u1170\u11c2"],["\ud4cc","\u1111\u1171"],["\ud4cd","\u1111\u1171\u11a8"],["\ud4ce","\u1111\u1171\u11a9"],["\ud4cf","\u1111\u1171\u11aa"],["\ud4d0","\u1111\u1171\u11ab"],["\ud4d1","\u1111\u1171\u11ac"],["\ud4d2","\u1111\u1171\u11ad"],["\ud4d3","\u1111\u1171\u11ae"],["\ud4d4","\u1111\u1171\u11af"],["\ud4d5","\u1111\u1171\u11b0"],["\ud4d6","\u1111\u1171\u11b1"],["\ud4d7","\u1111\u1171\u11b2"],["\ud4d8","\u1111\u1171\u11b3"],["\ud4d9","\u1111\u1171\u11b4"],["\ud4da","\u1111\u1171\u11b5"],["\ud4db","\u1111\u1171\u11b6"],["\ud4dc","\u1111\u1171\u11b7"],["\ud4dd","\u1111\u1171\u11b8"],["\ud4de","\u1111\u1171\u11b9"],["\ud4df","\u1111\u1171\u11ba"],["\ud4e0","\u1111\u1171\u11bb"],["\ud4e1","\u1111\u1171\u11bc"],["\ud4e2","\u1111\u1171\u11bd"],["\ud4e3","\u1111\u1171\u11be"],["\ud4e4","\u1111\u1171\u11bf"],["\ud4e5","\u1111\u1171\u11c0"],["\ud4e6","\u1111\u1171\u11c1"],["\ud4e7","\u1111\u1171\u11c2"],["\ud4e8","\u1111\u1172"],["\ud4e9","\u1111\u1172\u11a8"],["\ud4ea","\u1111\u1172\u11a9"],["\ud4eb","\u1111\u1172\u11aa"],["\ud4ec","\u1111\u1172\u11ab"],["\ud4ed","\u1111\u1172\u11ac"],["\ud4ee","\u1111\u1172\u11ad"],["\ud4ef","\u1111\u1172\u11ae"],["\ud4f0","\u1111\u1172\u11af"],["\ud4f1","\u1111\u1172\u11b0"],["\ud4f2","\u1111\u1172\u11b1"],["\ud4f3","\u1111\u1172\u11b2"],["\ud4f4","\u1111\u1172\u11b3"],["\ud4f5","\u1111\u1172\u11b4"],["\ud4f6","\u1111\u1172\u11b5"],["\ud4f7","\u1111\u1172\u11b6"],["\ud4f8","\u1111\u1172\u11b7"],["\ud4f9","\u1111\u1172\u11b8"],["\ud4fa","\u1111\u1172\u11b9"],["\ud4fb","\u1111\u1172\u11ba"],["\ud4fc","\u1111\u1172\u11bb"],["\ud4fd","\u1111\u1172\u11bc"],["\ud4fe","\u1111\u1172\u11bd"],["\ud4ff","\u1111\u1172\u11be"],["\ud500","\u1111\u1172\u11bf"],["\ud501","\u1111\u1172\u11c0"],["\ud502","\u1111\u1172\u11c1"],["\ud503","\u1111\u1172\u11c2"],["\ud504","\u1111\u1173"],["\ud505","\u1111\u1173\u11a8"],["\ud506","\u1111\u1173\u11a9"],["\ud507","\u1111\u1173\u11aa"],["\ud508","\u1111\u1173\u11ab"],["\ud509","\u1111\u1173\u11ac"],["\ud50a","\u1111\u1173\u11ad"],["\ud50b","\u1111\u1173\u11ae"],["\ud50c","\u1111\u1173\u11af"],["\ud50d","\u1111\u1173\u11b0"],["\ud50e","\u1111\u1173\u11b1"],["\ud50f","\u1111\u1173\u11b2"],["\ud510","\u1111\u1173\u11b3"],["\ud511","\u1111\u1173\u11b4"],["\ud512","\u1111\u1173\u11b5"],["\ud513","\u1111\u1173\u11b6"],["\ud514","\u1111\u1173\u11b7"],["\ud515","\u1111\u1173\u11b8"],["\ud516","\u1111\u1173\u11b9"],["\ud517","\u1111\u1173\u11ba"],["\ud518","\u1111\u1173\u11bb"],["\ud519","\u1111\u1173\u11bc"],["\ud51a","\u1111\u1173\u11bd"],["\ud51b","\u1111\u1173\u11be"],["\ud51c","\u1111\u1173\u11bf"],["\ud51d","\u1111\u1173\u11c0"],["\ud51e","\u1111\u1173\u11c1"],["\ud51f","\u1111\u1173\u11c2"],["\ud520","\u1111\u1174"],["\ud521","\u1111\u1174\u11a8"],["\ud522","\u1111\u1174\u11a9"],["\ud523","\u1111\u1174\u11aa"],["\ud524","\u1111\u1174\u11ab"],["\ud525","\u1111\u1174\u11ac"],["\ud526","\u1111\u1174\u11ad"],["\ud527","\u1111\u1174\u11ae"],["\ud528","\u1111\u1174\u11af"],["\ud529","\u1111\u1174\u11b0"],["\ud52a","\u1111\u1174\u11b1"],["\ud52b","\u1111\u1174\u11b2"],["\ud52c","\u1111\u1174\u11b3"],["\ud52d","\u1111\u1174\u11b4"],["\ud52e","\u1111\u1174\u11b5"],["\ud52f","\u1111\u1174\u11b6"],["\ud530","\u1111\u1174\u11b7"],["\ud531","\u1111\u1174\u11b8"],["\ud532","\u1111\u1174\u11b9"],["\ud533","\u1111\u1174\u11ba"],["\ud534","\u1111\u1174\u11bb"],["\ud535","\u1111\u1174\u11bc"],["\ud536","\u1111\u1174\u11bd"],["\ud537","\u1111\u1174\u11be"],["\ud538","\u1111\u1174\u11bf"],["\ud539","\u1111\u1174\u11c0"],["\ud53a","\u1111\u1174\u11c1"],["\ud53b","\u1111\u1174\u11c2"],["\ud53c","\u1111\u1175"],["\ud53d","\u1111\u1175\u11a8"],["\ud53e","\u1111\u1175\u11a9"],["\ud53f","\u1111\u1175\u11aa"],["\ud540","\u1111\u1175\u11ab"],["\ud541","\u1111\u1175\u11ac"],["\ud542","\u1111\u1175\u11ad"],["\ud543","\u1111\u1175\u11ae"],["\ud544","\u1111\u1175\u11af"],["\ud545","\u1111\u1175\u11b0"],["\ud546","\u1111\u1175\u11b1"],["\ud547","\u1111\u1175\u11b2"],["\ud548","\u1111\u1175\u11b3"],["\ud549","\u1111\u1175\u11b4"],["\ud54a","\u1111\u1175\u11b5"],["\ud54b","\u1111\u1175\u11b6"],["\ud54c","\u1111\u1175\u11b7"],["\ud54d","\u1111\u1175\u11b8"],["\ud54e","\u1111\u1175\u11b9"],["\ud54f","\u1111\u1175\u11ba"],["\ud550","\u1111\u1175\u11bb"],["\ud551","\u1111\u1175\u11bc"],["\ud552","\u1111\u1175\u11bd"],["\ud553","\u1111\u1175\u11be"],["\ud554","\u1111\u1175\u11bf"],["\ud555","\u1111\u1175\u11c0"],["\ud556","\u1111\u1175\u11c1"],["\ud557","\u1111\u1175\u11c2"],["\ud558","\u1112\u1161"],["\ud559","\u1112\u1161\u11a8"],["\ud55a","\u1112\u1161\u11a9"],["\ud55b","\u1112\u1161\u11aa"],["\ud55c","\u1112\u1161\u11ab"],["\ud55d","\u1112\u1161\u11ac"],["\ud55e","\u1112\u1161\u11ad"],["\ud55f","\u1112\u1161\u11ae"],["\ud560","\u1112\u1161\u11af"],["\ud561","\u1112\u1161\u11b0"],["\ud562","\u1112\u1161\u11b1"],["\ud563","\u1112\u1161\u11b2"],["\ud564","\u1112\u1161\u11b3"],["\ud565","\u1112\u1161\u11b4"],["\ud566","\u1112\u1161\u11b5"],["\ud567","\u1112\u1161\u11b6"],["\ud568","\u1112\u1161\u11b7"],["\ud569","\u1112\u1161\u11b8"],["\ud56a","\u1112\u1161\u11b9"],["\ud56b","\u1112\u1161\u11ba"],["\ud56c","\u1112\u1161\u11bb"],["\ud56d","\u1112\u1161\u11bc"],["\ud56e","\u1112\u1161\u11bd"],["\ud56f","\u1112\u1161\u11be"],["\ud570","\u1112\u1161\u11bf"],["\ud571","\u1112\u1161\u11c0"],["\ud572","\u1112\u1161\u11c1"],["\ud573","\u1112\u1161\u11c2"],["\ud574","\u1112\u1162"],["\ud575","\u1112\u1162\u11a8"],["\ud576","\u1112\u1162\u11a9"],["\ud577","\u1112\u1162\u11aa"],["\ud578","\u1112\u1162\u11ab"],["\ud579","\u1112\u1162\u11ac"],["\ud57a","\u1112\u1162\u11ad"],["\ud57b","\u1112\u1162\u11ae"],["\ud57c","\u1112\u1162\u11af"],["\ud57d","\u1112\u1162\u11b0"],["\ud57e","\u1112\u1162\u11b1"],["\ud57f","\u1112\u1162\u11b2"],["\ud580","\u1112\u1162\u11b3"],["\ud581","\u1112\u1162\u11b4"],["\ud582","\u1112\u1162\u11b5"],["\ud583","\u1112\u1162\u11b6"],["\ud584","\u1112\u1162\u11b7"],["\ud585","\u1112\u1162\u11b8"],["\ud586","\u1112\u1162\u11b9"],["\ud587","\u1112\u1162\u11ba"],["\ud588","\u1112\u1162\u11bb"],["\ud589","\u1112\u1162\u11bc"],["\ud58a","\u1112\u1162\u11bd"],["\ud58b","\u1112\u1162\u11be"],["\ud58c","\u1112\u1162\u11bf"],["\ud58d","\u1112\u1162\u11c0"],["\ud58e","\u1112\u1162\u11c1"],["\ud58f","\u1112\u1162\u11c2"],["\ud590","\u1112\u1163"],["\ud591","\u1112\u1163\u11a8"],["\ud592","\u1112\u1163\u11a9"],["\ud593","\u1112\u1163\u11aa"],["\ud594","\u1112\u1163\u11ab"],["\ud595","\u1112\u1163\u11ac"],["\ud596","\u1112\u1163\u11ad"],["\ud597","\u1112\u1163\u11ae"],["\ud598","\u1112\u1163\u11af"],["\ud599","\u1112\u1163\u11b0"],["\ud59a","\u1112\u1163\u11b1"],["\ud59b","\u1112\u1163\u11b2"],["\ud59c","\u1112\u1163\u11b3"],["\ud59d","\u1112\u1163\u11b4"],["\ud59e","\u1112\u1163\u11b5"],["\ud59f","\u1112\u1163\u11b6"],["\ud5a0","\u1112\u1163\u11b7"],["\ud5a1","\u1112\u1163\u11b8"],["\ud5a2","\u1112\u1163\u11b9"],["\ud5a3","\u1112\u1163\u11ba"],["\ud5a4","\u1112\u1163\u11bb"],["\ud5a5","\u1112\u1163\u11bc"],["\ud5a6","\u1112\u1163\u11bd"],["\ud5a7","\u1112\u1163\u11be"],["\ud5a8","\u1112\u1163\u11bf"],["\ud5a9","\u1112\u1163\u11c0"],["\ud5aa","\u1112\u1163\u11c1"],["\ud5ab","\u1112\u1163\u11c2"],["\ud5ac","\u1112\u1164"],["\ud5ad","\u1112\u1164\u11a8"],["\ud5ae","\u1112\u1164\u11a9"],["\ud5af","\u1112\u1164\u11aa"],["\ud5b0","\u1112\u1164\u11ab"],["\ud5b1","\u1112\u1164\u11ac"],["\ud5b2","\u1112\u1164\u11ad"],["\ud5b3","\u1112\u1164\u11ae"],["\ud5b4","\u1112\u1164\u11af"],["\ud5b5","\u1112\u1164\u11b0"],["\ud5b6","\u1112\u1164\u11b1"],["\ud5b7","\u1112\u1164\u11b2"],["\ud5b8","\u1112\u1164\u11b3"],["\ud5b9","\u1112\u1164\u11b4"],["\ud5ba","\u1112\u1164\u11b5"],["\ud5bb","\u1112\u1164\u11b6"],["\ud5bc","\u1112\u1164\u11b7"],["\ud5bd","\u1112\u1164\u11b8"],["\ud5be","\u1112\u1164\u11b9"],["\ud5bf","\u1112\u1164\u11ba"],["\ud5c0","\u1112\u1164\u11bb"],["\ud5c1","\u1112\u1164\u11bc"],["\ud5c2","\u1112\u1164\u11bd"],["\ud5c3","\u1112\u1164\u11be"],["\ud5c4","\u1112\u1164\u11bf"],["\ud5c5","\u1112\u1164\u11c0"],["\ud5c6","\u1112\u1164\u11c1"],["\ud5c7","\u1112\u1164\u11c2"],["\ud5c8","\u1112\u1165"],["\ud5c9","\u1112\u1165\u11a8"],["\ud5ca","\u1112\u1165\u11a9"],["\ud5cb","\u1112\u1165\u11aa"],["\ud5cc","\u1112\u1165\u11ab"],["\ud5cd","\u1112\u1165\u11ac"],["\ud5ce","\u1112\u1165\u11ad"],["\ud5cf","\u1112\u1165\u11ae"],["\ud5d0","\u1112\u1165\u11af"],["\ud5d1","\u1112\u1165\u11b0"],["\ud5d2","\u1112\u1165\u11b1"],["\ud5d3","\u1112\u1165\u11b2"],["\ud5d4","\u1112\u1165\u11b3"],["\ud5d5","\u1112\u1165\u11b4"],["\ud5d6","\u1112\u1165\u11b5"],["\ud5d7","\u1112\u1165\u11b6"],["\ud5d8","\u1112\u1165\u11b7"],["\ud5d9","\u1112\u1165\u11b8"],["\ud5da","\u1112\u1165\u11b9"],["\ud5db","\u1112\u1165\u11ba"],["\ud5dc","\u1112\u1165\u11bb"],["\ud5dd","\u1112\u1165\u11bc"],["\ud5de","\u1112\u1165\u11bd"],["\ud5df","\u1112\u1165\u11be"],["\ud5e0","\u1112\u1165\u11bf"],["\ud5e1","\u1112\u1165\u11c0"],["\ud5e2","\u1112\u1165\u11c1"],["\ud5e3","\u1112\u1165\u11c2"],["\ud5e4","\u1112\u1166"],["\ud5e5","\u1112\u1166\u11a8"],["\ud5e6","\u1112\u1166\u11a9"],["\ud5e7","\u1112\u1166\u11aa"],["\ud5e8","\u1112\u1166\u11ab"],["\ud5e9","\u1112\u1166\u11ac"],["\ud5ea","\u1112\u1166\u11ad"],["\ud5eb","\u1112\u1166\u11ae"],["\ud5ec","\u1112\u1166\u11af"],["\ud5ed","\u1112\u1166\u11b0"],["\ud5ee","\u1112\u1166\u11b1"],["\ud5ef","\u1112\u1166\u11b2"],["\ud5f0","\u1112\u1166\u11b3"],["\ud5f1","\u1112\u1166\u11b4"],["\ud5f2","\u1112\u1166\u11b5"],["\ud5f3","\u1112\u1166\u11b6"],["\ud5f4","\u1112\u1166\u11b7"],["\ud5f5","\u1112\u1166\u11b8"],["\ud5f6","\u1112\u1166\u11b9"],["\ud5f7","\u1112\u1166\u11ba"],["\ud5f8","\u1112\u1166\u11bb"],["\ud5f9","\u1112\u1166\u11bc"],["\ud5fa","\u1112\u1166\u11bd"],["\ud5fb","\u1112\u1166\u11be"],["\ud5fc","\u1112\u1166\u11bf"],["\ud5fd","\u1112\u1166\u11c0"],["\ud5fe","\u1112\u1166\u11c1"],["\ud5ff","\u1112\u1166\u11c2"],["\ud600","\u1112\u1167"],["\ud601","\u1112\u1167\u11a8"],["\ud602","\u1112\u1167\u11a9"],["\ud603","\u1112\u1167\u11aa"],["\ud604","\u1112\u1167\u11ab"],["\ud605","\u1112\u1167\u11ac"],["\ud606","\u1112\u1167\u11ad"],["\ud607","\u1112\u1167\u11ae"],["\ud608","\u1112\u1167\u11af"],["\ud609","\u1112\u1167\u11b0"],["\ud60a","\u1112\u1167\u11b1"],["\ud60b","\u1112\u1167\u11b2"],["\ud60c","\u1112\u1167\u11b3"],["\ud60d","\u1112\u1167\u11b4"],["\ud60e","\u1112\u1167\u11b5"],["\ud60f","\u1112\u1167\u11b6"],["\ud610","\u1112\u1167\u11b7"],["\ud611","\u1112\u1167\u11b8"],["\ud612","\u1112\u1167\u11b9"],["\ud613","\u1112\u1167\u11ba"],["\ud614","\u1112\u1167\u11bb"],["\ud615","\u1112\u1167\u11bc"],["\ud616","\u1112\u1167\u11bd"],["\ud617","\u1112\u1167\u11be"],["\ud618","\u1112\u1167\u11bf"],["\ud619","\u1112\u1167\u11c0"],["\ud61a","\u1112\u1167\u11c1"],["\ud61b","\u1112\u1167\u11c2"],["\ud61c","\u1112\u1168"],["\ud61d","\u1112\u1168\u11a8"],["\ud61e","\u1112\u1168\u11a9"],["\ud61f","\u1112\u1168\u11aa"],["\ud620","\u1112\u1168\u11ab"],["\ud621","\u1112\u1168\u11ac"],["\ud622","\u1112\u1168\u11ad"],["\ud623","\u1112\u1168\u11ae"],["\ud624","\u1112\u1168\u11af"],["\ud625","\u1112\u1168\u11b0"],["\ud626","\u1112\u1168\u11b1"],["\ud627","\u1112\u1168\u11b2"],["\ud628","\u1112\u1168\u11b3"],["\ud629","\u1112\u1168\u11b4"],["\ud62a","\u1112\u1168\u11b5"],["\ud62b","\u1112\u1168\u11b6"],["\ud62c","\u1112\u1168\u11b7"],["\ud62d","\u1112\u1168\u11b8"],["\ud62e","\u1112\u1168\u11b9"],["\ud62f","\u1112\u1168\u11ba"],["\ud630","\u1112\u1168\u11bb"],["\ud631","\u1112\u1168\u11bc"],["\ud632","\u1112\u1168\u11bd"],["\ud633","\u1112\u1168\u11be"],["\ud634","\u1112\u1168\u11bf"],["\ud635","\u1112\u1168\u11c0"],["\ud636","\u1112\u1168\u11c1"],["\ud637","\u1112\u1168\u11c2"],["\ud638","\u1112\u1169"],["\ud639","\u1112\u1169\u11a8"],["\ud63a","\u1112\u1169\u11a9"],["\ud63b","\u1112\u1169\u11aa"],["\ud63c","\u1112\u1169\u11ab"],["\ud63d","\u1112\u1169\u11ac"],["\ud63e","\u1112\u1169\u11ad"],["\ud63f","\u1112\u1169\u11ae"],["\ud640","\u1112\u1169\u11af"],["\ud641","\u1112\u1169\u11b0"],["\ud642","\u1112\u1169\u11b1"],["\ud643","\u1112\u1169\u11b2"],["\ud644","\u1112\u1169\u11b3"],["\ud645","\u1112\u1169\u11b4"],["\ud646","\u1112\u1169\u11b5"],["\ud647","\u1112\u1169\u11b6"],["\ud648","\u1112\u1169\u11b7"],["\ud649","\u1112\u1169\u11b8"],["\ud64a","\u1112\u1169\u11b9"],["\ud64b","\u1112\u1169\u11ba"],["\ud64c","\u1112\u1169\u11bb"],["\ud64d","\u1112\u1169\u11bc"],["\ud64e","\u1112\u1169\u11bd"],["\ud64f","\u1112\u1169\u11be"],["\ud650","\u1112\u1169\u11bf"],["\ud651","\u1112\u1169\u11c0"],["\ud652","\u1112\u1169\u11c1"],["\ud653","\u1112\u1169\u11c2"],["\ud654","\u1112\u116a"],["\ud655","\u1112\u116a\u11a8"],["\ud656","\u1112\u116a\u11a9"],["\ud657","\u1112\u116a\u11aa"],["\ud658","\u1112\u116a\u11ab"],["\ud659","\u1112\u116a\u11ac"],["\ud65a","\u1112\u116a\u11ad"],["\ud65b","\u1112\u116a\u11ae"],["\ud65c","\u1112\u116a\u11af"],["\ud65d","\u1112\u116a\u11b0"],["\ud65e","\u1112\u116a\u11b1"],["\ud65f","\u1112\u116a\u11b2"],["\ud660","\u1112\u116a\u11b3"],["\ud661","\u1112\u116a\u11b4"],["\ud662","\u1112\u116a\u11b5"],["\ud663","\u1112\u116a\u11b6"],["\ud664","\u1112\u116a\u11b7"],["\ud665","\u1112\u116a\u11b8"],["\ud666","\u1112\u116a\u11b9"],["\ud667","\u1112\u116a\u11ba"],["\ud668","\u1112\u116a\u11bb"],["\ud669","\u1112\u116a\u11bc"],["\ud66a","\u1112\u116a\u11bd"],["\ud66b","\u1112\u116a\u11be"],["\ud66c","\u1112\u116a\u11bf"],["\ud66d","\u1112\u116a\u11c0"],["\ud66e","\u1112\u116a\u11c1"],["\ud66f","\u1112\u116a\u11c2"],["\ud670","\u1112\u116b"],["\ud671","\u1112\u116b\u11a8"],["\ud672","\u1112\u116b\u11a9"],["\ud673","\u1112\u116b\u11aa"],["\ud674","\u1112\u116b\u11ab"],["\ud675","\u1112\u116b\u11ac"],["\ud676","\u1112\u116b\u11ad"],["\ud677","\u1112\u116b\u11ae"],["\ud678","\u1112\u116b\u11af"],["\ud679","\u1112\u116b\u11b0"],["\ud67a","\u1112\u116b\u11b1"],["\ud67b","\u1112\u116b\u11b2"],["\ud67c","\u1112\u116b\u11b3"],["\ud67d","\u1112\u116b\u11b4"],["\ud67e","\u1112\u116b\u11b5"],["\ud67f","\u1112\u116b\u11b6"],["\ud680","\u1112\u116b\u11b7"],["\ud681","\u1112\u116b\u11b8"],["\ud682","\u1112\u116b\u11b9"],["\ud683","\u1112\u116b\u11ba"],["\ud684","\u1112\u116b\u11bb"],["\ud685","\u1112\u116b\u11bc"],["\ud686","\u1112\u116b\u11bd"],["\ud687","\u1112\u116b\u11be"],["\ud688","\u1112\u116b\u11bf"],["\ud689","\u1112\u116b\u11c0"],["\ud68a","\u1112\u116b\u11c1"],["\ud68b","\u1112\u116b\u11c2"],["\ud68c","\u1112\u116c"],["\ud68d","\u1112\u116c\u11a8"],["\ud68e","\u1112\u116c\u11a9"],["\ud68f","\u1112\u116c\u11aa"],["\ud690","\u1112\u116c\u11ab"],["\ud691","\u1112\u116c\u11ac"],["\ud692","\u1112\u116c\u11ad"],["\ud693","\u1112\u116c\u11ae"],["\ud694","\u1112\u116c\u11af"],["\ud695","\u1112\u116c\u11b0"],["\ud696","\u1112\u116c\u11b1"],["\ud697","\u1112\u116c\u11b2"],["\ud698","\u1112\u116c\u11b3"],["\ud699","\u1112\u116c\u11b4"],["\ud69a","\u1112\u116c\u11b5"],["\ud69b","\u1112\u116c\u11b6"],["\ud69c","\u1112\u116c\u11b7"],["\ud69d","\u1112\u116c\u11b8"],["\ud69e","\u1112\u116c\u11b9"],["\ud69f","\u1112\u116c\u11ba"],["\ud6a0","\u1112\u116c\u11bb"],["\ud6a1","\u1112\u116c\u11bc"],["\ud6a2","\u1112\u116c\u11bd"],["\ud6a3","\u1112\u116c\u11be"],["\ud6a4","\u1112\u116c\u11bf"],["\ud6a5","\u1112\u116c\u11c0"],["\ud6a6","\u1112\u116c\u11c1"],["\ud6a7","\u1112\u116c\u11c2"],["\ud6a8","\u1112\u116d"],["\ud6a9","\u1112\u116d\u11a8"],["\ud6aa","\u1112\u116d\u11a9"],["\ud6ab","\u1112\u116d\u11aa"],["\ud6ac","\u1112\u116d\u11ab"],["\ud6ad","\u1112\u116d\u11ac"],["\ud6ae","\u1112\u116d\u11ad"],["\ud6af","\u1112\u116d\u11ae"],["\ud6b0","\u1112\u116d\u11af"],["\ud6b1","\u1112\u116d\u11b0"],["\ud6b2","\u1112\u116d\u11b1"],["\ud6b3","\u1112\u116d\u11b2"],["\ud6b4","\u1112\u116d\u11b3"],["\ud6b5","\u1112\u116d\u11b4"],["\ud6b6","\u1112\u116d\u11b5"],["\ud6b7","\u1112\u116d\u11b6"],["\ud6b8","\u1112\u116d\u11b7"],["\ud6b9","\u1112\u116d\u11b8"],["\ud6ba","\u1112\u116d\u11b9"],["\ud6bb","\u1112\u116d\u11ba"],["\ud6bc","\u1112\u116d\u11bb"],["\ud6bd","\u1112\u116d\u11bc"],["\ud6be","\u1112\u116d\u11bd"],["\ud6bf","\u1112\u116d\u11be"],["\ud6c0","\u1112\u116d\u11bf"],["\ud6c1","\u1112\u116d\u11c0"],["\ud6c2","\u1112\u116d\u11c1"],["\ud6c3","\u1112\u116d\u11c2"],["\ud6c4","\u1112\u116e"],["\ud6c5","\u1112\u116e\u11a8"],["\ud6c6","\u1112\u116e\u11a9"],["\ud6c7","\u1112\u116e\u11aa"],["\ud6c8","\u1112\u116e\u11ab"],["\ud6c9","\u1112\u116e\u11ac"],["\ud6ca","\u1112\u116e\u11ad"],["\ud6cb","\u1112\u116e\u11ae"],["\ud6cc","\u1112\u116e\u11af"],["\ud6cd","\u1112\u116e\u11b0"],["\ud6ce","\u1112\u116e\u11b1"],["\ud6cf","\u1112\u116e\u11b2"],["\ud6d0","\u1112\u116e\u11b3"],["\ud6d1","\u1112\u116e\u11b4"],["\ud6d2","\u1112\u116e\u11b5"],["\ud6d3","\u1112\u116e\u11b6"],["\ud6d4","\u1112\u116e\u11b7"],["\ud6d5","\u1112\u116e\u11b8"],["\ud6d6","\u1112\u116e\u11b9"],["\ud6d7","\u1112\u116e\u11ba"],["\ud6d8","\u1112\u116e\u11bb"],["\ud6d9","\u1112\u116e\u11bc"],["\ud6da","\u1112\u116e\u11bd"],["\ud6db","\u1112\u116e\u11be"],["\ud6dc","\u1112\u116e\u11bf"],["\ud6dd","\u1112\u116e\u11c0"],["\ud6de","\u1112\u116e\u11c1"],["\ud6df","\u1112\u116e\u11c2"],["\ud6e0","\u1112\u116f"],["\ud6e1","\u1112\u116f\u11a8"],["\ud6e2","\u1112\u116f\u11a9"],["\ud6e3","\u1112\u116f\u11aa"],["\ud6e4","\u1112\u116f\u11ab"],["\ud6e5","\u1112\u116f\u11ac"],["\ud6e6","\u1112\u116f\u11ad"],["\ud6e7","\u1112\u116f\u11ae"],["\ud6e8","\u1112\u116f\u11af"],["\ud6e9","\u1112\u116f\u11b0"],["\ud6ea","\u1112\u116f\u11b1"],["\ud6eb","\u1112\u116f\u11b2"],["\ud6ec","\u1112\u116f\u11b3"],["\ud6ed","\u1112\u116f\u11b4"],["\ud6ee","\u1112\u116f\u11b5"],["\ud6ef","\u1112\u116f\u11b6"],["\ud6f0","\u1112\u116f\u11b7"],["\ud6f1","\u1112\u116f\u11b8"],["\ud6f2","\u1112\u116f\u11b9"],["\ud6f3","\u1112\u116f\u11ba"],["\ud6f4","\u1112\u116f\u11bb"],["\ud6f5","\u1112\u116f\u11bc"],["\ud6f6","\u1112\u116f\u11bd"],["\ud6f7","\u1112\u116f\u11be"],["\ud6f8","\u1112\u116f\u11bf"],["\ud6f9","\u1112\u116f\u11c0"],["\ud6fa","\u1112\u116f\u11c1"],["\ud6fb","\u1112\u116f\u11c2"],["\ud6fc","\u1112\u1170"],["\ud6fd","\u1112\u1170\u11a8"],["\ud6fe","\u1112\u1170\u11a9"],["\ud6ff","\u1112\u1170\u11aa"],["\ud700","\u1112\u1170\u11ab"],["\ud701","\u1112\u1170\u11ac"],["\ud702","\u1112\u1170\u11ad"],["\ud703","\u1112\u1170\u11ae"],["\ud704","\u1112\u1170\u11af"],["\ud705","\u1112\u1170\u11b0"],["\ud706","\u1112\u1170\u11b1"],["\ud707","\u1112\u1170\u11b2"],["\ud708","\u1112\u1170\u11b3"],["\ud709","\u1112\u1170\u11b4"],["\ud70a","\u1112\u1170\u11b5"],["\ud70b","\u1112\u1170\u11b6"],["\ud70c","\u1112\u1170\u11b7"],["\ud70d","\u1112\u1170\u11b8"],["\ud70e","\u1112\u1170\u11b9"],["\ud70f","\u1112\u1170\u11ba"],["\ud710","\u1112\u1170\u11bb"],["\ud711","\u1112\u1170\u11bc"],["\ud712","\u1112\u1170\u11bd"],["\ud713","\u1112\u1170\u11be"],["\ud714","\u1112\u1170\u11bf"],["\ud715","\u1112\u1170\u11c0"],["\ud716","\u1112\u1170\u11c1"],["\ud717","\u1112\u1170\u11c2"],["\ud718","\u1112\u1171"],["\ud719","\u1112\u1171\u11a8"],["\ud71a","\u1112\u1171\u11a9"],["\ud71b","\u1112\u1171\u11aa"],["\ud71c","\u1112\u1171\u11ab"],["\ud71d","\u1112\u1171\u11ac"],["\ud71e","\u1112\u1171\u11ad"],["\ud71f","\u1112\u1171\u11ae"],["\ud720","\u1112\u1171\u11af"],["\ud721","\u1112\u1171\u11b0"],["\ud722","\u1112\u1171\u11b1"],["\ud723","\u1112\u1171\u11b2"],["\ud724","\u1112\u1171\u11b3"],["\ud725","\u1112\u1171\u11b4"],["\ud726","\u1112\u1171\u11b5"],["\ud727","\u1112\u1171\u11b6"],["\ud728","\u1112\u1171\u11b7"],["\ud729","\u1112\u1171\u11b8"],["\ud72a","\u1112\u1171\u11b9"],["\ud72b","\u1112\u1171\u11ba"],["\ud72c","\u1112\u1171\u11bb"],["\ud72d","\u1112\u1171\u11bc"],["\ud72e","\u1112\u1171\u11bd"],["\ud72f","\u1112\u1171\u11be"],["\ud730","\u1112\u1171\u11bf"],["\ud731","\u1112\u1171\u11c0"],["\ud732","\u1112\u1171\u11c1"],["\ud733","\u1112\u1171\u11c2"],["\ud734","\u1112\u1172"],["\ud735","\u1112\u1172\u11a8"],["\ud736","\u1112\u1172\u11a9"],["\ud737","\u1112\u1172\u11aa"],["\ud738","\u1112\u1172\u11ab"],["\ud739","\u1112\u1172\u11ac"],["\ud73a","\u1112\u1172\u11ad"],["\ud73b","\u1112\u1172\u11ae"],["\ud73c","\u1112\u1172\u11af"],["\ud73d","\u1112\u1172\u11b0"],["\ud73e","\u1112\u1172\u11b1"],["\ud73f","\u1112\u1172\u11b2"],["\ud740","\u1112\u1172\u11b3"],["\ud741","\u1112\u1172\u11b4"],["\ud742","\u1112\u1172\u11b5"],["\ud743","\u1112\u1172\u11b6"],["\ud744","\u1112\u1172\u11b7"],["\ud745","\u1112\u1172\u11b8"],["\ud746","\u1112\u1172\u11b9"],["\ud747","\u1112\u1172\u11ba"],["\ud748","\u1112\u1172\u11bb"],["\ud749","\u1112\u1172\u11bc"],["\ud74a","\u1112\u1172\u11bd"],["\ud74b","\u1112\u1172\u11be"],["\ud74c","\u1112\u1172\u11bf"],["\ud74d","\u1112\u1172\u11c0"],["\ud74e","\u1112\u1172\u11c1"],["\ud74f","\u1112\u1172\u11c2"],["\ud750","\u1112\u1173"],["\ud751","\u1112\u1173\u11a8"],["\ud752","\u1112\u1173\u11a9"],["\ud753","\u1112\u1173\u11aa"],["\ud754","\u1112\u1173\u11ab"],["\ud755","\u1112\u1173\u11ac"],["\ud756","\u1112\u1173\u11ad"],["\ud757","\u1112\u1173\u11ae"],["\ud758","\u1112\u1173\u11af"],["\ud759","\u1112\u1173\u11b0"],["\ud75a","\u1112\u1173\u11b1"],["\ud75b","\u1112\u1173\u11b2"],["\ud75c","\u1112\u1173\u11b3"],["\ud75d","\u1112\u1173\u11b4"],["\ud75e","\u1112\u1173\u11b5"],["\ud75f","\u1112\u1173\u11b6"],["\ud760","\u1112\u1173\u11b7"],["\ud761","\u1112\u1173\u11b8"],["\ud762","\u1112\u1173\u11b9"],["\ud763","\u1112\u1173\u11ba"],["\ud764","\u1112\u1173\u11bb"],["\ud765","\u1112\u1173\u11bc"],["\ud766","\u1112\u1173\u11bd"],["\ud767","\u1112\u1173\u11be"],["\ud768","\u1112\u1173\u11bf"],["\ud769","\u1112\u1173\u11c0"],["\ud76a","\u1112\u1173\u11c1"],["\ud76b","\u1112\u1173\u11c2"],["\ud76c","\u1112\u1174"],["\ud76d","\u1112\u1174\u11a8"],["\ud76e","\u1112\u1174\u11a9"],["\ud76f","\u1112\u1174\u11aa"],["\ud770","\u1112\u1174\u11ab"],["\ud771","\u1112\u1174\u11ac"],["\ud772","\u1112\u1174\u11ad"],["\ud773","\u1112\u1174\u11ae"],["\ud774","\u1112\u1174\u11af"],["\ud775","\u1112\u1174\u11b0"],["\ud776","\u1112\u1174\u11b1"],["\ud777","\u1112\u1174\u11b2"],["\ud778","\u1112\u1174\u11b3"],["\ud779","\u1112\u1174\u11b4"],["\ud77a","\u1112\u1174\u11b5"],["\ud77b","\u1112\u1174\u11b6"],["\ud77c","\u1112\u1174\u11b7"],["\ud77d","\u1112\u1174\u11b8"],["\ud77e","\u1112\u1174\u11b9"],["\ud77f","\u1112\u1174\u11ba"],["\ud780","\u1112\u1174\u11bb"],["\ud781","\u1112\u1174\u11bc"],["\ud782","\u1112\u1174\u11bd"],["\ud783","\u1112\u1174\u11be"],["\ud784","\u1112\u1174\u11bf"],["\ud785","\u1112\u1174\u11c0"],["\ud786","\u1112\u1174\u11c1"],["\ud787","\u1112\u1174\u11c2"],["\ud788","\u1112\u1175"],["\ud789","\u1112\u1175\u11a8"],["\ud78a","\u1112\u1175\u11a9"],["\ud78b","\u1112\u1175\u11aa"],["\ud78c","\u1112\u1175\u11ab"],["\ud78d","\u1112\u1175\u11ac"],["\ud78e","\u1112\u1175\u11ad"],["\ud78f","\u1112\u1175\u11ae"],["\ud790","\u1112\u1175\u11af"],["\ud791","\u1112\u1175\u11b0"],["\ud792","\u1112\u1175\u11b1"],["\ud793","\u1112\u1175\u11b2"],["\ud794","\u1112\u1175\u11b3"],["\ud795","\u1112\u1175\u11b4"],["\ud796","\u1112\u1175\u11b5"],["\ud797","\u1112\u1175\u11b6"],["\ud798","\u1112\u1175\u11b7"],["\ud799","\u1112\u1175\u11b8"],["\ud79a","\u1112\u1175\u11b9"],["\ud79b","\u1112\u1175\u11ba"],["\ud79c","\u1112\u1175\u11bb"],["\ud79d","\u1112\u1175\u11bc"],["\ud79e","\u1112\u1175\u11bd"],["\ud79f","\u1112\u1175\u11be"],["\ud7a0","\u1112\u1175\u11bf"],["\ud7a1","\u1112\u1175\u11c0"],["\ud7a2","\u1112\u1175\u11c1"],["\ud7a3","\u1112\u1175\u11c2"],["\uf900","\u8c48"],["\uf901","\u66f4"],["\uf902","\u8eca"],["\uf903","\u8cc8"],["\uf904","\u6ed1"],["\uf905","\u4e32"],["\uf906","\u53e5"],["\uf907","\u9f9c"],["\uf908","\u9f9c"],["\uf909","\u5951"],["\uf90a","\u91d1"],["\uf90b","\u5587"],["\uf90c","\u5948"],["\uf90d","\u61f6"],["\uf90e","\u7669"],["\uf90f","\u7f85"],["\uf910","\u863f"],["\uf911","\u87ba"],["\uf912","\u88f8"],["\uf913","\u908f"],["\uf914","\u6a02"],["\uf915","\u6d1b"],["\uf916","\u70d9"],["\uf917","\u73de"],["\uf918","\u843d"],["\uf919","\u916a"],["\uf91a","\u99f1"],["\uf91b","\u4e82"],["\uf91c","\u5375"],["\uf91d","\u6b04"],["\uf91e","\u721b"],["\uf91f","\u862d"],["\uf920","\u9e1e"],["\uf921","\u5d50"],["\uf922","\u6feb"],["\uf923","\u85cd"],["\uf924","\u8964"],["\uf925","\u62c9"],["\uf926","\u81d8"],["\uf927","\u881f"],["\uf928","\u5eca"],["\uf929","\u6717"],["\uf92a","\u6d6a"],["\uf92b","\u72fc"],["\uf92c","\u90ce"],["\uf92d","\u4f86"],["\uf92e","\u51b7"],["\uf92f","\u52de"],["\uf930","\u64c4"],["\uf931","\u6ad3"],["\uf932","\u7210"],["\uf933","\u76e7"],["\uf934","\u8001"],["\uf935","\u8606"],["\uf936","\u865c"],["\uf937","\u8def"],["\uf938","\u9732"],["\uf939","\u9b6f"],["\uf93a","\u9dfa"],["\uf93b","\u788c"],["\uf93c","\u797f"],["\uf93d","\u7da0"],["\uf93e","\u83c9"],["\uf93f","\u9304"],["\uf940","\u9e7f"],["\uf941","\u8ad6"],["\uf942","\u58df"],["\uf943","\u5f04"],["\uf944","\u7c60"],["\uf945","\u807e"],["\uf946","\u7262"],["\uf947","\u78ca"],["\uf948","\u8cc2"],["\uf949","\u96f7"],["\uf94a","\u58d8"],["\uf94b","\u5c62"],["\uf94c","\u6a13"],["\uf94d","\u6dda"],["\uf94e","\u6f0f"],["\uf94f","\u7d2f"],["\uf950","\u7e37"],["\uf951","\u964b"],["\uf952","\u52d2"],["\uf953","\u808b"],["\uf954","\u51dc"],["\uf955","\u51cc"],["\uf956","\u7a1c"],["\uf957","\u7dbe"],["\uf958","\u83f1"],["\uf959","\u9675"],["\uf95a","\u8b80"],["\uf95b","\u62cf"],["\uf95c","\u6a02"],["\uf95d","\u8afe"],["\uf95e","\u4e39"],["\uf95f","\u5be7"],["\uf960","\u6012"],["\uf961","\u7387"],["\uf962","\u7570"],["\uf963","\u5317"],["\uf964","\u78fb"],["\uf965","\u4fbf"],["\uf966","\u5fa9"],["\uf967","\u4e0d"],["\uf968","\u6ccc"],["\uf969","\u6578"],["\uf96a","\u7d22"],["\uf96b","\u53c3"],["\uf96c","\u585e"],["\uf96d","\u7701"],["\uf96e","\u8449"],["\uf96f","\u8aaa"],["\uf970","\u6bba"],["\uf971","\u8fb0"],["\uf972","\u6c88"],["\uf973","\u62fe"],["\uf974","\u82e5"],["\uf975","\u63a0"],["\uf976","\u7565"],["\uf977","\u4eae"],["\uf978","\u5169"],["\uf979","\u51c9"],["\uf97a","\u6881"],["\uf97b","\u7ce7"],["\uf97c","\u826f"],["\uf97d","\u8ad2"],["\uf97e","\u91cf"],["\uf97f","\u52f5"],["\uf980","\u5442"],["\uf981","\u5973"],["\uf982","\u5eec"],["\uf983","\u65c5"],["\uf984","\u6ffe"],["\uf985","\u792a"],["\uf986","\u95ad"],["\uf987","\u9a6a"],["\uf988","\u9e97"],["\uf989","\u9ece"],["\uf98a","\u529b"],["\uf98b","\u66c6"],["\uf98c","\u6b77"],["\uf98d","\u8f62"],["\uf98e","\u5e74"],["\uf98f","\u6190"],["\uf990","\u6200"],["\uf991","\u649a"],["\uf992","\u6f23"],["\uf993","\u7149"],["\uf994","\u7489"],["\uf995","\u79ca"],["\uf996","\u7df4"],["\uf997","\u806f"],["\uf998","\u8f26"],["\uf999","\u84ee"],["\uf99a","\u9023"],["\uf99b","\u934a"],["\uf99c","\u5217"],["\uf99d","\u52a3"],["\uf99e","\u54bd"],["\uf99f","\u70c8"],["\uf9a0","\u88c2"],["\uf9a1","\u8aaa"],["\uf9a2","\u5ec9"],["\uf9a3","\u5ff5"],["\uf9a4","\u637b"],["\uf9a5","\u6bae"],["\uf9a6","\u7c3e"],["\uf9a7","\u7375"],["\uf9a8","\u4ee4"],["\uf9a9","\u56f9"],["\uf9aa","\u5be7"],["\uf9ab","\u5dba"],["\uf9ac","\u601c"],["\uf9ad","\u73b2"],["\uf9ae","\u7469"],["\uf9af","\u7f9a"],["\uf9b0","\u8046"],["\uf9b1","\u9234"],["\uf9b2","\u96f6"],["\uf9b3","\u9748"],["\uf9b4","\u9818"],["\uf9b5","\u4f8b"],["\uf9b6","\u79ae"],["\uf9b7","\u91b4"],["\uf9b8","\u96b8"],["\uf9b9","\u60e1"],["\uf9ba","\u4e86"],["\uf9bb","\u50da"],["\uf9bc","\u5bee"],["\uf9bd","\u5c3f"],["\uf9be","\u6599"],["\uf9bf","\u6a02"],["\uf9c0","\u71ce"],["\uf9c1","\u7642"],["\uf9c2","\u84fc"],["\uf9c3","\u907c"],["\uf9c4","\u9f8d"],["\uf9c5","\u6688"],["\uf9c6","\u962e"],["\uf9c7","\u5289"],["\uf9c8","\u677b"],["\uf9c9","\u67f3"],["\uf9ca","\u6d41"],["\uf9cb","\u6e9c"],["\uf9cc","\u7409"],["\uf9cd","\u7559"],["\uf9ce","\u786b"],["\uf9cf","\u7d10"],["\uf9d0","\u985e"],["\uf9d1","\u516d"],["\uf9d2","\u622e"],["\uf9d3","\u9678"],["\uf9d4","\u502b"],["\uf9d5","\u5d19"],["\uf9d6","\u6dea"],["\uf9d7","\u8f2a"],["\uf9d8","\u5f8b"],["\uf9d9","\u6144"],["\uf9da","\u6817"],["\uf9db","\u7387"],["\uf9dc","\u9686"],["\uf9dd","\u5229"],["\uf9de","\u540f"],["\uf9df","\u5c65"],["\uf9e0","\u6613"],["\uf9e1","\u674e"],["\uf9e2","\u68a8"],["\uf9e3","\u6ce5"],["\uf9e4","\u7406"],["\uf9e5","\u75e2"],["\uf9e6","\u7f79"],["\uf9e7","\u88cf"],["\uf9e8","\u88e1"],["\uf9e9","\u91cc"],["\uf9ea","\u96e2"],["\uf9eb","\u533f"],["\uf9ec","\u6eba"],["\uf9ed","\u541d"],["\uf9ee","\u71d0"],["\uf9ef","\u7498"],["\uf9f0","\u85fa"],["\uf9f1","\u96a3"],["\uf9f2","\u9c57"],["\uf9f3","\u9e9f"],["\uf9f4","\u6797"],["\uf9f5","\u6dcb"],["\uf9f6","\u81e8"],["\uf9f7","\u7acb"],["\uf9f8","\u7b20"],["\uf9f9","\u7c92"],["\uf9fa","\u72c0"],["\uf9fb","\u7099"],["\uf9fc","\u8b58"],["\uf9fd","\u4ec0"],["\uf9fe","\u8336"],["\uf9ff","\u523a"],["\ufa00","\u5207"],["\ufa01","\u5ea6"],["\ufa02","\u62d3"],["\ufa03","\u7cd6"],["\ufa04","\u5b85"],["\ufa05","\u6d1e"],["\ufa06","\u66b4"],["\ufa07","\u8f3b"],["\ufa08","\u884c"],["\ufa09","\u964d"],["\ufa0a","\u898b"],["\ufa0b","\u5ed3"],["\ufa0c","\u5140"],["\ufa0d","\u55c0"],["\ufa10","\u585a"],["\ufa12","\u6674"],["\ufa15","\u51de"],["\ufa16","\u732a"],["\ufa17","\u76ca"],["\ufa18","\u793c"],["\ufa19","\u795e"],["\ufa1a","\u7965"],["\ufa1b","\u798f"],["\ufa1c","\u9756"],["\ufa1d","\u7cbe"],["\ufa1e","\u7fbd"],["\ufa20","\u8612"],["\ufa22","\u8af8"],["\ufa25","\u9038"],["\ufa26","\u90fd"],["\ufa2a","\u98ef"],["\ufa2b","\u98fc"],["\ufa2c","\u9928"],["\ufa2d","\u9db4"],["\ufa2e","\u90de"],["\ufa2f","\u96b7"],["\ufa30","\u4fae"],["\ufa31","\u50e7"],["\ufa32","\u514d"],["\ufa33","\u52c9"],["\ufa34","\u52e4"],["\ufa35","\u5351"],["\ufa36","\u559d"],["\ufa37","\u5606"],["\ufa38","\u5668"],["\ufa39","\u5840"],["\ufa3a","\u58a8"],["\ufa3b","\u5c64"],["\ufa3c","\u5c6e"],["\ufa3d","\u6094"],["\ufa3e","\u6168"],["\ufa3f","\u618e"],["\ufa40","\u61f2"],["\ufa41","\u654f"],["\ufa42","\u65e2"],["\ufa43","\u6691"],["\ufa44","\u6885"],["\ufa45","\u6d77"],["\ufa46","\u6e1a"],["\ufa47","\u6f22"],["\ufa48","\u716e"],["\ufa49","\u722b"],["\ufa4a","\u7422"],["\ufa4b","\u7891"],["\ufa4c","\u793e"],["\ufa4d","\u7949"],["\ufa4e","\u7948"],["\ufa4f","\u7950"],["\ufa50","\u7956"],["\ufa51","\u795d"],["\ufa52","\u798d"],["\ufa53","\u798e"],["\ufa54","\u7a40"],["\ufa55","\u7a81"],["\ufa56","\u7bc0"],["\ufa57","\u7df4"],["\ufa58","\u7e09"],["\ufa59","\u7e41"],["\ufa5a","\u7f72"],["\ufa5b","\u8005"],["\ufa5c","\u81ed"],["\ufa5d","\u8279"],["\ufa5e","\u8279"],["\ufa5f","\u8457"],["\ufa60","\u8910"],["\ufa61","\u8996"],["\ufa62","\u8b01"],["\ufa63","\u8b39"],["\ufa64","\u8cd3"],["\ufa65","\u8d08"],["\ufa66","\u8fb6"],["\ufa67","\u9038"],["\ufa68","\u96e3"],["\ufa69","\u97ff"],["\ufa6a","\u983b"],["\ufa6b","\u6075"],["\ufa6c","\ud850\udeee"],["\ufa6d","\u8218"],["\ufa70","\u4e26"],["\ufa71","\u51b5"],["\ufa72","\u5168"],["\ufa73","\u4f80"],["\ufa74","\u5145"],["\ufa75","\u5180"],["\ufa76","\u52c7"],["\ufa77","\u52fa"],["\ufa78","\u559d"],["\ufa79","\u5555"],["\ufa7a","\u5599"],["\ufa7b","\u55e2"],["\ufa7c","\u585a"],["\ufa7d","\u58b3"],["\ufa7e","\u5944"],["\ufa7f","\u5954"],["\ufa80","\u5a62"],["\ufa81","\u5b28"],["\ufa82","\u5ed2"],["\ufa83","\u5ed9"],["\ufa84","\u5f69"],["\ufa85","\u5fad"],["\ufa86","\u60d8"],["\ufa87","\u614e"],["\ufa88","\u6108"],["\ufa89","\u618e"],["\ufa8a","\u6160"],["\ufa8b","\u61f2"],["\ufa8c","\u6234"],["\ufa8d","\u63c4"],["\ufa8e","\u641c"],["\ufa8f","\u6452"],["\ufa90","\u6556"],["\ufa91","\u6674"],["\ufa92","\u6717"],["\ufa93","\u671b"],["\ufa94","\u6756"],["\ufa95","\u6b79"],["\ufa96","\u6bba"],["\ufa97","\u6d41"],["\ufa98","\u6edb"],["\ufa99","\u6ecb"],["\ufa9a","\u6f22"],["\ufa9b","\u701e"],["\ufa9c","\u716e"],["\ufa9d","\u77a7"],["\ufa9e","\u7235"],["\ufa9f","\u72af"],["\ufaa0","\u732a"],["\ufaa1","\u7471"],["\ufaa2","\u7506"],["\ufaa3","\u753b"],["\ufaa4","\u761d"],["\ufaa5","\u761f"],["\ufaa6","\u76ca"],["\ufaa7","\u76db"],["\ufaa8","\u76f4"],["\ufaa9","\u774a"],["\ufaaa","\u7740"],["\ufaab","\u78cc"],["\ufaac","\u7ab1"],["\ufaad","\u7bc0"],["\ufaae","\u7c7b"],["\ufaaf","\u7d5b"],["\ufab0","\u7df4"],["\ufab1","\u7f3e"],["\ufab2","\u8005"],["\ufab3","\u8352"],["\ufab4","\u83ef"],["\ufab5","\u8779"],["\ufab6","\u8941"],["\ufab7","\u8986"],["\ufab8","\u8996"],["\ufab9","\u8abf"],["\ufaba","\u8af8"],["\ufabb","\u8acb"],["\ufabc","\u8b01"],["\ufabd","\u8afe"],["\ufabe","\u8aed"],["\ufabf","\u8b39"],["\ufac0","\u8b8a"],["\ufac1","\u8d08"],["\ufac2","\u8f38"],["\ufac3","\u9072"],["\ufac4","\u9199"],["\ufac5","\u9276"],["\ufac6","\u967c"],["\ufac7","\u96e3"],["\ufac8","\u9756"],["\ufac9","\u97db"],["\ufaca","\u97ff"],["\ufacb","\u980b"],["\ufacc","\u983b"],["\ufacd","\u9b12"],["\uface","\u9f9c"],["\ufacf","\ud84a\udc4a"],["\ufad0","\ud84a\udc44"],["\ufad1","\ud84c\udfd5"],["\ufad2","\u3b9d"],["\ufad3","\u4018"],["\ufad4","\u4039"],["\ufad5","\ud854\ude49"],["\ufad6","\ud857\udcd0"],["\ufad7","\ud85f\uded3"],["\ufad8","\u9f43"],["\ufad9","\u9f8e"],["\ufb00","ff"],["\ufb01","fi"],["\ufb02","fl"],["\ufb03","ffi"],["\ufb04","ffl"],["\ufb05","st"],["\ufb06","st"],["\ufb13","\u0574\u0576"],["\ufb14","\u0574\u0565"],["\ufb15","\u0574\u056b"],["\ufb16","\u057e\u0576"],["\ufb17","\u0574\u056d"],["\ufb1d","\u05d9"],["\ufb1e",""],["\ufb1f","\u05f2"],["\ufb20","\u05e2"],["\ufb21","\u05d0"],["\ufb22","\u05d3"],["\ufb23","\u05d4"],["\ufb24","\u05db"],["\ufb25","\u05dc"],["\ufb26","\u05dd"],["\ufb27","\u05e8"],["\ufb28","\u05ea"],["\ufb29","+"],["\ufb2a","\u05e9"],["\ufb2b","\u05e9"],["\ufb2c","\u05e9"],["\ufb2d","\u05e9"],["\ufb2e","\u05d0"],["\ufb2f","\u05d0"],["\ufb30","\u05d0"],["\ufb31","\u05d1"],["\ufb32","\u05d2"],["\ufb33","\u05d3"],["\ufb34","\u05d4"],["\ufb35","\u05d5"],["\ufb36","\u05d6"],["\ufb38","\u05d8"],["\ufb39","\u05d9"],["\ufb3a","\u05da"],["\ufb3b","\u05db"],["\ufb3c","\u05dc"],["\ufb3e","\u05de"],["\ufb40","\u05e0"],["\ufb41","\u05e1"],["\ufb43","\u05e3"],["\ufb44","\u05e4"],["\ufb46","\u05e6"],["\ufb47","\u05e7"],["\ufb48","\u05e8"],["\ufb49","\u05e9"],["\ufb4a","\u05ea"],["\ufb4b","\u05d5"],["\ufb4c","\u05d1"],["\ufb4d","\u05db"],["\ufb4e","\u05e4"],["\ufb4f","\u05d0\u05dc"],["\ufb50","\u0671"],["\ufb51","\u0671"],["\ufb52","\u067b"],["\ufb53","\u067b"],["\ufb54","\u067b"],["\ufb55","\u067b"],["\ufb56","\u067e"],["\ufb57","\u067e"],["\ufb58","\u067e"],["\ufb59","\u067e"],["\ufb5a","\u0680"],["\ufb5b","\u0680"],["\ufb5c","\u0680"],["\ufb5d","\u0680"],["\ufb5e","\u067a"],["\ufb5f","\u067a"],["\ufb60","\u067a"],["\ufb61","\u067a"],["\ufb62","\u067f"],["\ufb63","\u067f"],["\ufb64","\u067f"],["\ufb65","\u067f"],["\ufb66","\u0679"],["\ufb67","\u0679"],["\ufb68","\u0679"],["\ufb69","\u0679"],["\ufb6a","\u06a4"],["\ufb6b","\u06a4"],["\ufb6c","\u06a4"],["\ufb6d","\u06a4"],["\ufb6e","\u06a6"],["\ufb6f","\u06a6"],["\ufb70","\u06a6"],["\ufb71","\u06a6"],["\ufb72","\u0684"],["\ufb73","\u0684"],["\ufb74","\u0684"],["\ufb75","\u0684"],["\ufb76","\u0683"],["\ufb77","\u0683"],["\ufb78","\u0683"],["\ufb79","\u0683"],["\ufb7a","\u0686"],["\ufb7b","\u0686"],["\ufb7c","\u0686"],["\ufb7d","\u0686"],["\ufb7e","\u0687"],["\ufb7f","\u0687"],["\ufb80","\u0687"],["\ufb81","\u0687"],["\ufb82","\u068d"],["\ufb83","\u068d"],["\ufb84","\u068c"],["\ufb85","\u068c"],["\ufb86","\u068e"],["\ufb87","\u068e"],["\ufb88","\u0688"],["\ufb89","\u0688"],["\ufb8a","\u0698"],["\ufb8b","\u0698"],["\ufb8c","\u0691"],["\ufb8d","\u0691"],["\ufb8e","\u06a9"],["\ufb8f","\u06a9"],["\ufb90","\u06a9"],["\ufb91","\u06a9"],["\ufb92","\u06af"],["\ufb93","\u06af"],["\ufb94","\u06af"],["\ufb95","\u06af"],["\ufb96","\u06b3"],["\ufb97","\u06b3"],["\ufb98","\u06b3"],["\ufb99","\u06b3"],["\ufb9a","\u06b1"],["\ufb9b","\u06b1"],["\ufb9c","\u06b1"],["\ufb9d","\u06b1"],["\ufb9e","\u06ba"],["\ufb9f","\u06ba"],["\ufba0","\u06bb"],["\ufba1","\u06bb"],["\ufba2","\u06bb"],["\ufba3","\u06bb"],["\ufba4","\u06d5"],["\ufba5","\u06d5"],["\ufba6","\u06c1"],["\ufba7","\u06c1"],["\ufba8","\u06c1"],["\ufba9","\u06c1"],["\ufbaa","\u06be"],["\ufbab","\u06be"],["\ufbac","\u06be"],["\ufbad","\u06be"],["\ufbae","\u06d2"],["\ufbaf","\u06d2"],["\ufbb0","\u06d2"],["\ufbb1","\u06d2"],["\ufbd3","\u06ad"],["\ufbd4","\u06ad"],["\ufbd5","\u06ad"],["\ufbd6","\u06ad"],["\ufbd7","\u06c7"],["\ufbd8","\u06c7"],["\ufbd9","\u06c6"],["\ufbda","\u06c6"],["\ufbdb","\u06c8"],["\ufbdc","\u06c8"],["\ufbdd","\u06c7\u0674"],["\ufbde","\u06cb"],["\ufbdf","\u06cb"],["\ufbe0","\u06c5"],["\ufbe1","\u06c5"],["\ufbe2","\u06c9"],["\ufbe3","\u06c9"],["\ufbe4","\u06d0"],["\ufbe5","\u06d0"],["\ufbe6","\u06d0"],["\ufbe7","\u06d0"],["\ufbe8","\u0649"],["\ufbe9","\u0649"],["\ufbea","\u064a\u0627"],["\ufbeb","\u064a\u0627"],["\ufbec","\u064a\u06d5"],["\ufbed","\u064a\u06d5"],["\ufbee","\u064a\u0648"],["\ufbef","\u064a\u0648"],["\ufbf0","\u064a\u06c7"],["\ufbf1","\u064a\u06c7"],["\ufbf2","\u064a\u06c6"],["\ufbf3","\u064a\u06c6"],["\ufbf4","\u064a\u06c8"],["\ufbf5","\u064a\u06c8"],["\ufbf6","\u064a\u06d0"],["\ufbf7","\u064a\u06d0"],["\ufbf8","\u064a\u06d0"],["\ufbf9","\u064a\u0649"],["\ufbfa","\u064a\u0649"],["\ufbfb","\u064a\u0649"],["\ufbfc","\u06cc"],["\ufbfd","\u06cc"],["\ufbfe","\u06cc"],["\ufbff","\u06cc"],["\ufc00","\u064a\u062c"],["\ufc01","\u064a\u062d"],["\ufc02","\u064a\u0645"],["\ufc03","\u064a\u0649"],["\ufc04","\u064a\u064a"],["\ufc05","\u0628\u062c"],["\ufc06","\u0628\u062d"],["\ufc07","\u0628\u062e"],["\ufc08","\u0628\u0645"],["\ufc09","\u0628\u0649"],["\ufc0a","\u0628\u064a"],["\ufc0b","\u062a\u062c"],["\ufc0c","\u062a\u062d"],["\ufc0d","\u062a\u062e"],["\ufc0e","\u062a\u0645"],["\ufc0f","\u062a\u0649"],["\ufc10","\u062a\u064a"],["\ufc11","\u062b\u062c"],["\ufc12","\u062b\u0645"],["\ufc13","\u062b\u0649"],["\ufc14","\u062b\u064a"],["\ufc15","\u062c\u062d"],["\ufc16","\u062c\u0645"],["\ufc17","\u062d\u062c"],["\ufc18","\u062d\u0645"],["\ufc19","\u062e\u062c"],["\ufc1a","\u062e\u062d"],["\ufc1b","\u062e\u0645"],["\ufc1c","\u0633\u062c"],["\ufc1d","\u0633\u062d"],["\ufc1e","\u0633\u062e"],["\ufc1f","\u0633\u0645"],["\ufc20","\u0635\u062d"],["\ufc21","\u0635\u0645"],["\ufc22","\u0636\u062c"],["\ufc23","\u0636\u062d"],["\ufc24","\u0636\u062e"],["\ufc25","\u0636\u0645"],["\ufc26","\u0637\u062d"],["\ufc27","\u0637\u0645"],["\ufc28","\u0638\u0645"],["\ufc29","\u0639\u062c"],["\ufc2a","\u0639\u0645"],["\ufc2b","\u063a\u062c"],["\ufc2c","\u063a\u0645"],["\ufc2d","\u0641\u062c"],["\ufc2e","\u0641\u062d"],["\ufc2f","\u0641\u062e"],["\ufc30","\u0641\u0645"],["\ufc31","\u0641\u0649"],["\ufc32","\u0641\u064a"],["\ufc33","\u0642\u062d"],["\ufc34","\u0642\u0645"],["\ufc35","\u0642\u0649"],["\ufc36","\u0642\u064a"],["\ufc37","\u0643\u0627"],["\ufc38","\u0643\u062c"],["\ufc39","\u0643\u062d"],["\ufc3a","\u0643\u062e"],["\ufc3b","\u0643\u0644"],["\ufc3c","\u0643\u0645"],["\ufc3d","\u0643\u0649"],["\ufc3e","\u0643\u064a"],["\ufc3f","\u0644\u062c"],["\ufc40","\u0644\u062d"],["\ufc41","\u0644\u062e"],["\ufc42","\u0644\u0645"],["\ufc43","\u0644\u0649"],["\ufc44","\u0644\u064a"],["\ufc45","\u0645\u062c"],["\ufc46","\u0645\u062d"],["\ufc47","\u0645\u062e"],["\ufc48","\u0645\u0645"],["\ufc49","\u0645\u0649"],["\ufc4a","\u0645\u064a"],["\ufc4b","\u0646\u062c"],["\ufc4c","\u0646\u062d"],["\ufc4d","\u0646\u062e"],["\ufc4e","\u0646\u0645"],["\ufc4f","\u0646\u0649"],["\ufc50","\u0646\u064a"],["\ufc51","\u0647\u062c"],["\ufc52","\u0647\u0645"],["\ufc53","\u0647\u0649"],["\ufc54","\u0647\u064a"],["\ufc55","\u064a\u062c"],["\ufc56","\u064a\u062d"],["\ufc57","\u064a\u062e"],["\ufc58","\u064a\u0645"],["\ufc59","\u064a\u0649"],["\ufc5a","\u064a\u064a"],["\ufc5b","\u0630"],["\ufc5c","\u0631"],["\ufc5d","\u0649"],["\ufc5e"," "],["\ufc5f"," "],["\ufc60"," "],["\ufc61"," "],["\ufc62"," "],["\ufc63"," "],["\ufc64","\u064a\u0631"],["\ufc65","\u064a\u0632"],["\ufc66","\u064a\u0645"],["\ufc67","\u064a\u0646"],["\ufc68","\u064a\u0649"],["\ufc69","\u064a\u064a"],["\ufc6a","\u0628\u0631"],["\ufc6b","\u0628\u0632"],["\ufc6c","\u0628\u0645"],["\ufc6d","\u0628\u0646"],["\ufc6e","\u0628\u0649"],["\ufc6f","\u0628\u064a"],["\ufc70","\u062a\u0631"],["\ufc71","\u062a\u0632"],["\ufc72","\u062a\u0645"],["\ufc73","\u062a\u0646"],["\ufc74","\u062a\u0649"],["\ufc75","\u062a\u064a"],["\ufc76","\u062b\u0631"],["\ufc77","\u062b\u0632"],["\ufc78","\u062b\u0645"],["\ufc79","\u062b\u0646"],["\ufc7a","\u062b\u0649"],["\ufc7b","\u062b\u064a"],["\ufc7c","\u0641\u0649"],["\ufc7d","\u0641\u064a"],["\ufc7e","\u0642\u0649"],["\ufc7f","\u0642\u064a"],["\ufc80","\u0643\u0627"],["\ufc81","\u0643\u0644"],["\ufc82","\u0643\u0645"],["\ufc83","\u0643\u0649"],["\ufc84","\u0643\u064a"],["\ufc85","\u0644\u0645"],["\ufc86","\u0644\u0649"],["\ufc87","\u0644\u064a"],["\ufc88","\u0645\u0627"],["\ufc89","\u0645\u0645"],["\ufc8a","\u0646\u0631"],["\ufc8b","\u0646\u0632"],["\ufc8c","\u0646\u0645"],["\ufc8d","\u0646\u0646"],["\ufc8e","\u0646\u0649"],["\ufc8f","\u0646\u064a"],["\ufc90","\u0649"],["\ufc91","\u064a\u0631"],["\ufc92","\u064a\u0632"],["\ufc93","\u064a\u0645"],["\ufc94","\u064a\u0646"],["\ufc95","\u064a\u0649"],["\ufc96","\u064a\u064a"],["\ufc97","\u064a\u062c"],["\ufc98","\u064a\u062d"],["\ufc99","\u064a\u062e"],["\ufc9a","\u064a\u0645"],["\ufc9b","\u064a\u0647"],["\ufc9c","\u0628\u062c"],["\ufc9d","\u0628\u062d"],["\ufc9e","\u0628\u062e"],["\ufc9f","\u0628\u0645"],["\ufca0","\u0628\u0647"],["\ufca1","\u062a\u062c"],["\ufca2","\u062a\u062d"],["\ufca3","\u062a\u062e"],["\ufca4","\u062a\u0645"],["\ufca5","\u062a\u0647"],["\ufca6","\u062b\u0645"],["\ufca7","\u062c\u062d"],["\ufca8","\u062c\u0645"],["\ufca9","\u062d\u062c"],["\ufcaa","\u062d\u0645"],["\ufcab","\u062e\u062c"],["\ufcac","\u062e\u0645"],["\ufcad","\u0633\u062c"],["\ufcae","\u0633\u062d"],["\ufcaf","\u0633\u062e"],["\ufcb0","\u0633\u0645"],["\ufcb1","\u0635\u062d"],["\ufcb2","\u0635\u062e"],["\ufcb3","\u0635\u0645"],["\ufcb4","\u0636\u062c"],["\ufcb5","\u0636\u062d"],["\ufcb6","\u0636\u062e"],["\ufcb7","\u0636\u0645"],["\ufcb8","\u0637\u062d"],["\ufcb9","\u0638\u0645"],["\ufcba","\u0639\u062c"],["\ufcbb","\u0639\u0645"],["\ufcbc","\u063a\u062c"],["\ufcbd","\u063a\u0645"],["\ufcbe","\u0641\u062c"],["\ufcbf","\u0641\u062d"],["\ufcc0","\u0641\u062e"],["\ufcc1","\u0641\u0645"],["\ufcc2","\u0642\u062d"],["\ufcc3","\u0642\u0645"],["\ufcc4","\u0643\u062c"],["\ufcc5","\u0643\u062d"],["\ufcc6","\u0643\u062e"],["\ufcc7","\u0643\u0644"],["\ufcc8","\u0643\u0645"],["\ufcc9","\u0644\u062c"],["\ufcca","\u0644\u062d"],["\ufccb","\u0644\u062e"],["\ufccc","\u0644\u0645"],["\ufccd","\u0644\u0647"],["\ufcce","\u0645\u062c"],["\ufccf","\u0645\u062d"],["\ufcd0","\u0645\u062e"],["\ufcd1","\u0645\u0645"],["\ufcd2","\u0646\u062c"],["\ufcd3","\u0646\u062d"],["\ufcd4","\u0646\u062e"],["\ufcd5","\u0646\u0645"],["\ufcd6","\u0646\u0647"],["\ufcd7","\u0647\u062c"],["\ufcd8","\u0647\u0645"],["\ufcd9","\u0647"],["\ufcda","\u064a\u062c"],["\ufcdb","\u064a\u062d"],["\ufcdc","\u064a\u062e"],["\ufcdd","\u064a\u0645"],["\ufcde","\u064a\u0647"],["\ufcdf","\u064a\u0645"],["\ufce0","\u064a\u0647"],["\ufce1","\u0628\u0645"],["\ufce2","\u0628\u0647"],["\ufce3","\u062a\u0645"],["\ufce4","\u062a\u0647"],["\ufce5","\u062b\u0645"],["\ufce6","\u062b\u0647"],["\ufce7","\u0633\u0645"],["\ufce8","\u0633\u0647"],["\ufce9","\u0634\u0645"],["\ufcea","\u0634\u0647"],["\ufceb","\u0643\u0644"],["\ufcec","\u0643\u0645"],["\ufced","\u0644\u0645"],["\ufcee","\u0646\u0645"],["\ufcef","\u0646\u0647"],["\ufcf0","\u064a\u0645"],["\ufcf1","\u064a\u0647"],["\ufcf2","\u0640"],["\ufcf3","\u0640"],["\ufcf4","\u0640"],["\ufcf5","\u0637\u0649"],["\ufcf6","\u0637\u064a"],["\ufcf7","\u0639\u0649"],["\ufcf8","\u0639\u064a"],["\ufcf9","\u063a\u0649"],["\ufcfa","\u063a\u064a"],["\ufcfb","\u0633\u0649"],["\ufcfc","\u0633\u064a"],["\ufcfd","\u0634\u0649"],["\ufcfe","\u0634\u064a"],["\ufcff","\u062d\u0649"],["\ufd00","\u062d\u064a"],["\ufd01","\u062c\u0649"],["\ufd02","\u062c\u064a"],["\ufd03","\u062e\u0649"],["\ufd04","\u062e\u064a"],["\ufd05","\u0635\u0649"],["\ufd06","\u0635\u064a"],["\ufd07","\u0636\u0649"],["\ufd08","\u0636\u064a"],["\ufd09","\u0634\u062c"],["\ufd0a","\u0634\u062d"],["\ufd0b","\u0634\u062e"],["\ufd0c","\u0634\u0645"],["\ufd0d","\u0634\u0631"],["\ufd0e","\u0633\u0631"],["\ufd0f","\u0635\u0631"],["\ufd10","\u0636\u0631"],["\ufd11","\u0637\u0649"],["\ufd12","\u0637\u064a"],["\ufd13","\u0639\u0649"],["\ufd14","\u0639\u064a"],["\ufd15","\u063a\u0649"],["\ufd16","\u063a\u064a"],["\ufd17","\u0633\u0649"],["\ufd18","\u0633\u064a"],["\ufd19","\u0634\u0649"],["\ufd1a","\u0634\u064a"],["\ufd1b","\u062d\u0649"],["\ufd1c","\u062d\u064a"],["\ufd1d","\u062c\u0649"],["\ufd1e","\u062c\u064a"],["\ufd1f","\u062e\u0649"],["\ufd20","\u062e\u064a"],["\ufd21","\u0635\u0649"],["\ufd22","\u0635\u064a"],["\ufd23","\u0636\u0649"],["\ufd24","\u0636\u064a"],["\ufd25","\u0634\u062c"],["\ufd26","\u0634\u062d"],["\ufd27","\u0634\u062e"],["\ufd28","\u0634\u0645"],["\ufd29","\u0634\u0631"],["\ufd2a","\u0633\u0631"],["\ufd2b","\u0635\u0631"],["\ufd2c","\u0636\u0631"],["\ufd2d","\u0634\u062c"],["\ufd2e","\u0634\u062d"],["\ufd2f","\u0634\u062e"],["\ufd30","\u0634\u0645"],["\ufd31","\u0633\u0647"],["\ufd32","\u0634\u0647"],["\ufd33","\u0637\u0645"],["\ufd34","\u0633\u062c"],["\ufd35","\u0633\u062d"],["\ufd36","\u0633\u062e"],["\ufd37","\u0634\u062c"],["\ufd38","\u0634\u062d"],["\ufd39","\u0634\u062e"],["\ufd3a","\u0637\u0645"],["\ufd3b","\u0638\u0645"],["\ufd3c","\u0627"],["\ufd3d","\u0627"],["\ufd50","\u062a\u062c\u0645"],["\ufd51","\u062a\u062d\u062c"],["\ufd52","\u062a\u062d\u062c"],["\ufd53","\u062a\u062d\u0645"],["\ufd54","\u062a\u062e\u0645"],["\ufd55","\u062a\u0645\u062c"],["\ufd56","\u062a\u0645\u062d"],["\ufd57","\u062a\u0645\u062e"],["\ufd58","\u062c\u0645\u062d"],["\ufd59","\u062c\u0645\u062d"],["\ufd5a","\u062d\u0645\u064a"],["\ufd5b","\u062d\u0645\u0649"],["\ufd5c","\u0633\u062d\u062c"],["\ufd5d","\u0633\u062c\u062d"],["\ufd5e","\u0633\u062c\u0649"],["\ufd5f","\u0633\u0645\u062d"],["\ufd60","\u0633\u0645\u062d"],["\ufd61","\u0633\u0645\u062c"],["\ufd62","\u0633\u0645\u0645"],["\ufd63","\u0633\u0645\u0645"],["\ufd64","\u0635\u062d\u062d"],["\ufd65","\u0635\u062d\u062d"],["\ufd66","\u0635\u0645\u0645"],["\ufd67","\u0634\u062d\u0645"],["\ufd68","\u0634\u062d\u0645"],["\ufd69","\u0634\u062c\u064a"],["\ufd6a","\u0634\u0645\u062e"],["\ufd6b","\u0634\u0645\u062e"],["\ufd6c","\u0634\u0645\u0645"],["\ufd6d","\u0634\u0645\u0645"],["\ufd6e","\u0636\u062d\u0649"],["\ufd6f","\u0636\u062e\u0645"],["\ufd70","\u0636\u062e\u0645"],["\ufd71","\u0637\u0645\u062d"],["\ufd72","\u0637\u0645\u062d"],["\ufd73","\u0637\u0645\u0645"],["\ufd74","\u0637\u0645\u064a"],["\ufd75","\u0639\u062c\u0645"],["\ufd76","\u0639\u0645\u0645"],["\ufd77","\u0639\u0645\u0645"],["\ufd78","\u0639\u0645\u0649"],["\ufd79","\u063a\u0645\u0645"],["\ufd7a","\u063a\u0645\u064a"],["\ufd7b","\u063a\u0645\u0649"],["\ufd7c","\u0641\u062e\u0645"],["\ufd7d","\u0641\u062e\u0645"],["\ufd7e","\u0642\u0645\u062d"],["\ufd7f","\u0642\u0645\u0645"],["\ufd80","\u0644\u062d\u0645"],["\ufd81","\u0644\u062d\u064a"],["\ufd82","\u0644\u062d\u0649"],["\ufd83","\u0644\u062c\u062c"],["\ufd84","\u0644\u062c\u062c"],["\ufd85","\u0644\u062e\u0645"],["\ufd86","\u0644\u062e\u0645"],["\ufd87","\u0644\u0645\u062d"],["\ufd88","\u0644\u0645\u062d"],["\ufd89","\u0645\u062d\u062c"],["\ufd8a","\u0645\u062d\u0645"],["\ufd8b","\u0645\u062d\u064a"],["\ufd8c","\u0645\u062c\u062d"],["\ufd8d","\u0645\u062c\u0645"],["\ufd8e","\u0645\u062e\u062c"],["\ufd8f","\u0645\u062e\u0645"],["\ufd92","\u0645\u062c\u062e"],["\ufd93","\u0647\u0645\u062c"],["\ufd94","\u0647\u0645\u0645"],["\ufd95","\u0646\u062d\u0645"],["\ufd96","\u0646\u062d\u0649"],["\ufd97","\u0646\u062c\u0645"],["\ufd98","\u0646\u062c\u0645"],["\ufd99","\u0646\u062c\u0649"],["\ufd9a","\u0646\u0645\u064a"],["\ufd9b","\u0646\u0645\u0649"],["\ufd9c","\u064a\u0645\u0645"],["\ufd9d","\u064a\u0645\u0645"],["\ufd9e","\u0628\u062e\u064a"],["\ufd9f","\u062a\u062c\u064a"],["\ufda0","\u062a\u062c\u0649"],["\ufda1","\u062a\u062e\u064a"],["\ufda2","\u062a\u062e\u0649"],["\ufda3","\u062a\u0645\u064a"],["\ufda4","\u062a\u0645\u0649"],["\ufda5","\u062c\u0645\u064a"],["\ufda6","\u062c\u062d\u0649"],["\ufda7","\u062c\u0645\u0649"],["\ufda8","\u0633\u062e\u0649"],["\ufda9","\u0635\u062d\u064a"],["\ufdaa","\u0634\u062d\u064a"],["\ufdab","\u0636\u062d\u064a"],["\ufdac","\u0644\u062c\u064a"],["\ufdad","\u0644\u0645\u064a"],["\ufdae","\u064a\u062d\u064a"],["\ufdaf","\u064a\u062c\u064a"],["\ufdb0","\u064a\u0645\u064a"],["\ufdb1","\u0645\u0645\u064a"],["\ufdb2","\u0642\u0645\u064a"],["\ufdb3","\u0646\u062d\u064a"],["\ufdb4","\u0642\u0645\u062d"],["\ufdb5","\u0644\u062d\u0645"],["\ufdb6","\u0639\u0645\u064a"],["\ufdb7","\u0643\u0645\u064a"],["\ufdb8","\u0646\u062c\u062d"],["\ufdb9","\u0645\u062e\u064a"],["\ufdba","\u0644\u062c\u0645"],["\ufdbb","\u0643\u0645\u0645"],["\ufdbc","\u0644\u062c\u0645"],["\ufdbd","\u0646\u062c\u062d"],["\ufdbe","\u062c\u062d\u064a"],["\ufdbf","\u062d\u062c\u064a"],["\ufdc0","\u0645\u062c\u064a"],["\ufdc1","\u0641\u0645\u064a"],["\ufdc2","\u0628\u062d\u064a"],["\ufdc3","\u0643\u0645\u0645"],["\ufdc4","\u0639\u062c\u0645"],["\ufdc5","\u0635\u0645\u0645"],["\ufdc6","\u0633\u062e\u064a"],["\ufdc7","\u0646\u062c\u064a"],["\ufdf0","\u0635\u0644\u06d2"],["\ufdf1","\u0642\u0644\u06d2"],["\ufdf2","\u0627\u0644\u0644\u0647"],["\ufdf3","\u0627\u0643\u0628\u0631"],["\ufdf4","\u0645\u062d\u0645\u062f"],["\ufdf5","\u0635\u0644\u0639\u0645"],["\ufdf6","\u0631\u0633\u0648\u0644"],["\ufdf7","\u0639\u0644\u064a\u0647"],["\ufdf8","\u0648\u0633\u0644\u0645"],["\ufdf9","\u0635\u0644\u0649"],["\ufdfa","\u0635\u0644\u0649 \u0627\u0644\u0644\u0647 \u0639\u0644\u064a\u0647 \u0648\u0633\u0644\u0645"],["\ufdfb","\u062c\u0644 \u062c\u0644\u0627\u0644\u0647"],["\ufdfc","\u0631\u06cc\u0627\u0644"],["\ufe10",","],["\ufe11","\u3001"],["\ufe12","\u3002"],["\ufe13",":"],["\ufe14",";"],["\ufe15","!"],["\ufe16","?"],["\ufe17","\u3016"],["\ufe18","\u3017"],["\ufe19","..."],["\ufe20",""],["\ufe21",""],["\ufe22",""],["\ufe23",""],["\ufe24",""],["\ufe25",""],["\ufe26",""],["\ufe27",""],["\ufe28",""],["\ufe29",""],["\ufe2a",""],["\ufe2b",""],["\ufe2c",""],["\ufe2d",""],["\ufe2e",""],["\ufe2f",""],["\ufe30",".."],["\ufe31","\u2014"],["\ufe32","\u2013"],["\ufe33","_"],["\ufe34","_"],["\ufe35","("],["\ufe36",")"],["\ufe37","{"],["\ufe38","}"],["\ufe39","\u3014"],["\ufe3a","\u3015"],["\ufe3b","\u3010"],["\ufe3c","\u3011"],["\ufe3d","\u300a"],["\ufe3e","\u300b"],["\ufe3f","\u3008"],["\ufe40","\u3009"],["\ufe41","\u300c"],["\ufe42","\u300d"],["\ufe43","\u300e"],["\ufe44","\u300f"],["\ufe47","["],["\ufe48","]"],["\ufe49"," "],["\ufe4a"," "],["\ufe4b"," "],["\ufe4c"," "],["\ufe4d","_"],["\ufe4e","_"],["\ufe4f","_"],["\ufe50",","],["\ufe51","\u3001"],["\ufe52","."],["\ufe54",";"],["\ufe55",":"],["\ufe56","?"],["\ufe57","!"],["\ufe58","\u2014"],["\ufe59","("],["\ufe5a",")"],["\ufe5b","{"],["\ufe5c","}"],["\ufe5d","\u3014"],["\ufe5e","\u3015"],["\ufe5f","#"],["\ufe60","&"],["\ufe61","*"],["\ufe62","+"],["\ufe63","-"],["\ufe64","<"],["\ufe65",">"],["\ufe66","="],["\ufe68","\\"],["\ufe69","$"],["\ufe6a","%"],["\ufe6b","@"],["\ufe70"," "],["\ufe71","\u0640"],["\ufe72"," "],["\ufe74"," "],["\ufe76"," "],["\ufe77","\u0640"],["\ufe78"," "],["\ufe79","\u0640"],["\ufe7a"," "],["\ufe7b","\u0640"],["\ufe7c"," "],["\ufe7d","\u0640"],["\ufe7e"," "],["\ufe7f","\u0640"],["\ufe80","\u0621"],["\ufe81","\u0627"],["\ufe82","\u0627"],["\ufe83","\u0627"],["\ufe84","\u0627"],["\ufe85","\u0648"],["\ufe86","\u0648"],["\ufe87","\u0627"],["\ufe88","\u0627"],["\ufe89","\u064a"],["\ufe8a","\u064a"],["\ufe8b","\u064a"],["\ufe8c","\u064a"],["\ufe8d","\u0627"],["\ufe8e","\u0627"],["\ufe8f","\u0628"],["\ufe90","\u0628"],["\ufe91","\u0628"],["\ufe92","\u0628"],["\ufe93","\u0629"],["\ufe94","\u0629"],["\ufe95","\u062a"],["\ufe96","\u062a"],["\ufe97","\u062a"],["\ufe98","\u062a"],["\ufe99","\u062b"],["\ufe9a","\u062b"],["\ufe9b","\u062b"],["\ufe9c","\u062b"],["\ufe9d","\u062c"],["\ufe9e","\u062c"],["\ufe9f","\u062c"],["\ufea0","\u062c"],["\ufea1","\u062d"],["\ufea2","\u062d"],["\ufea3","\u062d"],["\ufea4","\u062d"],["\ufea5","\u062e"],["\ufea6","\u062e"],["\ufea7","\u062e"],["\ufea8","\u062e"],["\ufea9","\u062f"],["\ufeaa","\u062f"],["\ufeab","\u0630"],["\ufeac","\u0630"],["\ufead","\u0631"],["\ufeae","\u0631"],["\ufeaf","\u0632"],["\ufeb0","\u0632"],["\ufeb1","\u0633"],["\ufeb2","\u0633"],["\ufeb3","\u0633"],["\ufeb4","\u0633"],["\ufeb5","\u0634"],["\ufeb6","\u0634"],["\ufeb7","\u0634"],["\ufeb8","\u0634"],["\ufeb9","\u0635"],["\ufeba","\u0635"],["\ufebb","\u0635"],["\ufebc","\u0635"],["\ufebd","\u0636"],["\ufebe","\u0636"],["\ufebf","\u0636"],["\ufec0","\u0636"],["\ufec1","\u0637"],["\ufec2","\u0637"],["\ufec3","\u0637"],["\ufec4","\u0637"],["\ufec5","\u0638"],["\ufec6","\u0638"],["\ufec7","\u0638"],["\ufec8","\u0638"],["\ufec9","\u0639"],["\ufeca","\u0639"],["\ufecb","\u0639"],["\ufecc","\u0639"],["\ufecd","\u063a"],["\ufece","\u063a"],["\ufecf","\u063a"],["\ufed0","\u063a"],["\ufed1","\u0641"],["\ufed2","\u0641"],["\ufed3","\u0641"],["\ufed4","\u0641"],["\ufed5","\u0642"],["\ufed6","\u0642"],["\ufed7","\u0642"],["\ufed8","\u0642"],["\ufed9","\u0643"],["\ufeda","\u0643"],["\ufedb","\u0643"],["\ufedc","\u0643"],["\ufedd","\u0644"],["\ufede","\u0644"],["\ufedf","\u0644"],["\ufee0","\u0644"],["\ufee1","\u0645"],["\ufee2","\u0645"],["\ufee3","\u0645"],["\ufee4","\u0645"],["\ufee5","\u0646"],["\ufee6","\u0646"],["\ufee7","\u0646"],["\ufee8","\u0646"],["\ufee9","\u0647"],["\ufeea","\u0647"],["\ufeeb","\u0647"],["\ufeec","\u0647"],["\ufeed","\u0648"],["\ufeee","\u0648"],["\ufeef","\u0649"],["\ufef0","\u0649"],["\ufef1","\u064a"],["\ufef2","\u064a"],["\ufef3","\u064a"],["\ufef4","\u064a"],["\ufef5","\u0644\u0627"],["\ufef6","\u0644\u0627"],["\ufef7","\u0644\u0627"],["\ufef8","\u0644\u0627"],["\ufef9","\u0644\u0627"],["\ufefa","\u0644\u0627"],["\ufefb","\u0644\u0627"],["\ufefc","\u0644\u0627"],["\uff01","!"],["\uff02","\""],["\uff03","#"],["\uff04","$"],["\uff05","%"],["\uff06","&"],["\uff07","'"],["\uff08","("],["\uff09",")"],["\uff0a","*"],["\uff0b","+"],["\uff0c",","],["\uff0d","-"],["\uff0e","."],["\uff0f","/"],["\uff10","0"],["\uff11","1"],["\uff12","2"],["\uff13","3"],["\uff14","4"],["\uff15","5"],["\uff16","6"],["\uff17","7"],["\uff18","8"],["\uff19","9"],["\uff1a",":"],["\uff1b",";"],["\uff1c","<"],["\uff1d","="],["\uff1e",">"],["\uff1f","?"],["\uff20","@"],["\uff21","a"],["\uff22","b"],["\uff23","c"],["\uff24","d"],["\uff25","e"],["\uff26","f"],["\uff27","g"],["\uff28","h"],["\uff29","i"],["\uff2a","j"],["\uff2b","k"],["\uff2c","l"],["\uff2d","m"],["\uff2e","n"],["\uff2f","o"],["\uff30","p"],["\uff31","q"],["\uff32","r"],["\uff33","s"],["\uff34","t"],["\uff35","u"],["\uff36","v"],["\uff37","w"],["\uff38","x"],["\uff39","y"],["\uff3a","z"],["\uff3b","["],["\uff3c","\\"],["\uff3d","]"],["\uff3e","^"],["\uff3f","_"],["\uff40","`"],["\uff41","a"],["\uff42","b"],["\uff43","c"],["\uff44","d"],["\uff45","e"],["\uff46","f"],["\uff47","g"],["\uff48","h"],["\uff49","i"],["\uff4a","j"],["\uff4b","k"],["\uff4c","l"],["\uff4d","m"],["\uff4e","n"],["\uff4f","o"],["\uff50","p"],["\uff51","q"],["\uff52","r"],["\uff53","s"],["\uff54","t"],["\uff55","u"],["\uff56","v"],["\uff57","w"],["\uff58","x"],["\uff59","y"],["\uff5a","z"],["\uff5b","{"],["\uff5c","|"],["\uff5d","}"],["\uff5e","~"],["\uff5f","\u2985"],["\uff60","\u2986"],["\uff61","\u3002"],["\uff62","\u300c"],["\uff63","\u300d"],["\uff64","\u3001"],["\uff65","\u30fb"],["\uff66","\u30f2"],["\uff67","\u30a1"],["\uff68","\u30a3"],["\uff69","\u30a5"],["\uff6a","\u30a7"],["\uff6b","\u30a9"],["\uff6c","\u30e3"],["\uff6d","\u30e5"],["\uff6e","\u30e7"],["\uff6f","\u30c3"],["\uff70","\u30fc"],["\uff71","\u30a2"],["\uff72","\u30a4"],["\uff73","\u30a6"],["\uff74","\u30a8"],["\uff75","\u30aa"],["\uff76","\u30ab"],["\uff77","\u30ad"],["\uff78","\u30af"],["\uff79","\u30b1"],["\uff7a","\u30b3"],["\uff7b","\u30b5"],["\uff7c","\u30b7"],["\uff7d","\u30b9"],["\uff7e","\u30bb"],["\uff7f","\u30bd"],["\uff80","\u30bf"],["\uff81","\u30c1"],["\uff82","\u30c4"],["\uff83","\u30c6"],["\uff84","\u30c8"],["\uff85","\u30ca"],["\uff86","\u30cb"],["\uff87","\u30cc"],["\uff88","\u30cd"],["\uff89","\u30ce"],["\uff8a","\u30cf"],["\uff8b","\u30d2"],["\uff8c","\u30d5"],["\uff8d","\u30d8"],["\uff8e","\u30db"],["\uff8f","\u30de"],["\uff90","\u30df"],["\uff91","\u30e0"],["\uff92","\u30e1"],["\uff93","\u30e2"],["\uff94","\u30e4"],["\uff95","\u30e6"],["\uff96","\u30e8"],["\uff97","\u30e9"],["\uff98","\u30ea"],["\uff99","\u30eb"],["\uff9a","\u30ec"],["\uff9b","\u30ed"],["\uff9c","\u30ef"],["\uff9d","\u30f3"],["\uff9e",""],["\uff9f",""],["\uffa0","\u1160"],["\uffa1","\u1100"],["\uffa2","\u1101"],["\uffa3","\u11aa"],["\uffa4","\u1102"],["\uffa5","\u11ac"],["\uffa6","\u11ad"],["\uffa7","\u1103"],["\uffa8","\u1104"],["\uffa9","\u1105"],["\uffaa","\u11b0"],["\uffab","\u11b1"],["\uffac","\u11b2"],["\uffad","\u11b3"],["\uffae","\u11b4"],["\uffaf","\u11b5"],["\uffb0","\u111a"],["\uffb1","\u1106"],["\uffb2","\u1107"],["\uffb3","\u1108"],["\uffb4","\u1121"],["\uffb5","\u1109"],["\uffb6","\u110a"],["\uffb7","\u110b"],["\uffb8","\u110c"],["\uffb9","\u110d"],["\uffba","\u110e"],["\uffbb","\u110f"],["\uffbc","\u1110"],["\uffbd","\u1111"],["\uffbe","\u1112"],["\uffc2","\u1161"],["\uffc3","\u1162"],["\uffc4","\u1163"],["\uffc5","\u1164"],["\uffc6","\u1165"],["\uffc7","\u1166"],["\uffca","\u1167"],["\uffcb","\u1168"],["\uffcc","\u1169"],["\uffcd","\u116a"],["\uffce","\u116b"],["\uffcf","\u116c"],["\uffd2","\u116d"],["\uffd3","\u116e"],["\uffd4","\u116f"],["\uffd5","\u1170"],["\uffd6","\u1171"],["\uffd7","\u1172"],["\uffda","\u1173"],["\uffdb","\u1174"],["\uffdc","\u1175"],["\uffe0","\u00a2"],["\uffe1","\u00a3"],["\uffe2","\u00ac"],["\uffe3"," "],["\uffe4","\u00a6"],["\uffe5","\u00a5"],["\uffe6","\u20a9"],["\uffe8","\u2502"],["\uffe9","\u2190"],["\uffea","\u2191"],["\uffeb","\u2192"],["\uffec","\u2193"],["\uffed","\u25a0"],["\uffee","\u25cb"],["\ud800\uddfd",""],["\ud800\udee0",""],["\ud800\udf76",""],["\ud800\udf77",""],["\ud800\udf78",""],["\ud800\udf79",""],["\ud800\udf7a",""],["\ud801\udc00","\ud801\udc28"],["\ud801\udc01","\ud801\udc29"],["\ud801\udc02","\ud801\udc2a"],["\ud801\udc03","\ud801\udc2b"],["\ud801\udc04","\ud801\udc2c"],["\ud801\udc05","\ud801\udc2d"],["\ud801\udc06","\ud801\udc2e"],["\ud801\udc07","\ud801\udc2f"],["\ud801\udc08","\ud801\udc30"],["\ud801\udc09","\ud801\udc31"],["\ud801\udc0a","\ud801\udc32"],["\ud801\udc0b","\ud801\udc33"],["\ud801\udc0c","\ud801\udc34"],["\ud801\udc0d","\ud801\udc35"],["\ud801\udc0e","\ud801\udc36"],["\ud801\udc0f","\ud801\udc37"],["\ud801\udc10","\ud801\udc38"],["\ud801\udc11","\ud801\udc39"],["\ud801\udc12","\ud801\udc3a"],["\ud801\udc13","\ud801\udc3b"],["\ud801\udc14","\ud801\udc3c"],["\ud801\udc15","\ud801\udc3d"],["\ud801\udc16","\ud801\udc3e"],["\ud801\udc17","\ud801\udc3f"],["\ud801\udc18","\ud801\udc40"],["\ud801\udc19","\ud801\udc41"],["\ud801\udc1a","\ud801\udc42"],["\ud801\udc1b","\ud801\udc43"],["\ud801\udc1c","\ud801\udc44"],["\ud801\udc1d","\ud801\udc45"],["\ud801\udc1e","\ud801\udc46"],["\ud801\udc1f","\ud801\udc47"],["\ud801\udc20","\ud801\udc48"],["\ud801\udc21","\ud801\udc49"],["\ud801\udc22","\ud801\udc4a"],["\ud801\udc23","\ud801\udc4b"],["\ud801\udc24","\ud801\udc4c"],["\ud801\udc25","\ud801\udc4d"],["\ud801\udc26","\ud801\udc4e"],["\ud801\udc27","\ud801\udc4f"],["\ud801\udcb0","\ud801\udcd8"],["\ud801\udcb1","\ud801\udcd9"],["\ud801\udcb2","\ud801\udcda"],["\ud801\udcb3","\ud801\udcdb"],["\ud801\udcb4","\ud801\udcdc"],["\ud801\udcb5","\ud801\udcdd"],["\ud801\udcb6","\ud801\udcde"],["\ud801\udcb7","\ud801\udcdf"],["\ud801\udcb8","\ud801\udce0"],["\ud801\udcb9","\ud801\udce1"],["\ud801\udcba","\ud801\udce2"],["\ud801\udcbb","\ud801\udce3"],["\ud801\udcbc","\ud801\udce4"],["\ud801\udcbd","\ud801\udce5"],["\ud801\udcbe","\ud801\udce6"],["\ud801\udcbf","\ud801\udce7"],["\ud801\udcc0","\ud801\udce8"],["\ud801\udcc1","\ud801\udce9"],["\ud801\udcc2","\ud801\udcea"],["\ud801\udcc3","\ud801\udceb"],["\ud801\udcc4","\ud801\udcec"],["\ud801\udcc5","\ud801\udced"],["\ud801\udcc6","\ud801\udcee"],["\ud801\udcc7","\ud801\udcef"],["\ud801\udcc8","\ud801\udcf0"],["\ud801\udcc9","\ud801\udcf1"],["\ud801\udcca","\ud801\udcf2"],["\ud801\udccb","\ud801\udcf3"],["\ud801\udccc","\ud801\udcf4"],["\ud801\udccd","\ud801\udcf5"],["\ud801\udcce","\ud801\udcf6"],["\ud801\udccf","\ud801\udcf7"],["\ud801\udcd0","\ud801\udcf8"],["\ud801\udcd1","\ud801\udcf9"],["\ud801\udcd2","\ud801\udcfa"],["\ud801\udcd3","\ud801\udcfb"],["\ud801\udd70","\ud801\udd97"],["\ud801\udd71","\ud801\udd98"],["\ud801\udd72","\ud801\udd99"],["\ud801\udd73","\ud801\udd9a"],["\ud801\udd74","\ud801\udd9b"],["\ud801\udd75","\ud801\udd9c"],["\ud801\udd76","\ud801\udd9d"],["\ud801\udd77","\ud801\udd9e"],["\ud801\udd78","\ud801\udd9f"],["\ud801\udd79","\ud801\udda0"],["\ud801\udd7a","\ud801\udda1"],["\ud801\udd7c","\ud801\udda3"],["\ud801\udd7d","\ud801\udda4"],["\ud801\udd7e","\ud801\udda5"],["\ud801\udd7f","\ud801\udda6"],["\ud801\udd80","\ud801\udda7"],["\ud801\udd81","\ud801\udda8"],["\ud801\udd82","\ud801\udda9"],["\ud801\udd83","\ud801\uddaa"],["\ud801\udd84","\ud801\uddab"],["\ud801\udd85","\ud801\uddac"],["\ud801\udd86","\ud801\uddad"],["\ud801\udd87","\ud801\uddae"],["\ud801\udd88","\ud801\uddaf"],["\ud801\udd89","\ud801\uddb0"],["\ud801\udd8a","\ud801\uddb1"],["\ud801\udd8c","\ud801\uddb3"],["\ud801\udd8d","\ud801\uddb4"],["\ud801\udd8e","\ud801\uddb5"],["\ud801\udd8f","\ud801\uddb6"],["\ud801\udd90","\ud801\uddb7"],["\ud801\udd91","\ud801\uddb8"],["\ud801\udd92","\ud801\uddb9"],["\ud801\udd94","\ud801\uddbb"],["\ud801\udd95","\ud801\uddbc"],["\ud801\uddc9","\ud801\uddd2"],["\ud801\udde4","\ud801\uddda"],["\ud801\udf81","\u02d0"],["\ud801\udf82","\u02d1"],["\ud801\udf83","\u00e6"],["\ud801\udf84","\u0299"],["\ud801\udf85","\u0253"],["\ud801\udf87","\u02a3"],["\ud801\udf88","\uab66"],["\ud801\udf89","\u02a5"],["\ud801\udf8a","\u02a4"],["\ud801\udf8b","\u0256"],["\ud801\udf8c","\u0257"],["\ud801\udf8d","\u1d91"],["\ud801\udf8e","\u0258"],["\ud801\udf8f","\u025e"],["\ud801\udf90","\u02a9"],["\ud801\udf91","\u0264"],["\ud801\udf92","\u0262"],["\ud801\udf93","\u0260"],["\ud801\udf94","\u029b"],["\ud801\udf95","\u0127"],["\ud801\udf96","\u029c"],["\ud801\udf97","\u0267"],["\ud801\udf98","\u0284"],["\ud801\udf99","\u02aa"],["\ud801\udf9a","\u02ab"],["\ud801\udf9b","\u026c"],["\ud801\udf9c","\ud837\udf04"],["\ud801\udf9d","\ua78e"],["\ud801\udf9e","\u026e"],["\ud801\udf9f","\ud837\udf05"],["\ud801\udfa0","\u028e"],["\ud801\udfa1","\ud837\udf06"],["\ud801\udfa2","\u00f8"],["\ud801\udfa3","\u0276"],["\ud801\udfa4","\u0277"],["\ud801\udfa5","q"],["\ud801\udfa6","\u027a"],["\ud801\udfa7","\ud837\udf08"],["\ud801\udfa8","\u027d"],["\ud801\udfa9","\u027e"],["\ud801\udfaa","\u0280"],["\ud801\udfab","\u02a8"],["\ud801\udfac","\u02a6"],["\ud801\udfad","\uab67"],["\ud801\udfae","\u02a7"],["\ud801\udfaf","\u0288"],["\ud801\udfb0","\u2c71"],["\ud801\udfb2","\u028f"],["\ud801\udfb3","\u02a1"],["\ud801\udfb4","\u02a2"],["\ud801\udfb5","\u0298"],["\ud801\udfb6","\u01c0"],["\ud801\udfb7","\u01c1"],["\ud801\udfb8","\u01c2"],["\ud801\udfb9","\ud837\udf0a"],["\ud801\udfba","\ud837\udf1e"],["\ud802\ude0d",""],["\ud802\ude0f",""],["\ud802\ude38",""],["\ud802\ude39",""],["\ud802\ude3a",""],["\ud802\ude3f",""],["\ud802\udee5",""],["\ud802\udee6",""],["\ud803\udc80","\ud803\udcc0"],["\ud803\udc81","\ud803\udcc1"],["\ud803\udc82","\ud803\udcc2"],["\ud803\udc83","\ud803\udcc3"],["\ud803\udc84","\ud803\udcc4"],["\ud803\udc85","\ud803\udcc5"],["\ud803\udc86","\ud803\udcc6"],["\ud803\udc87","\ud803\udcc7"],["\ud803\udc88","\ud803\udcc8"],["\ud803\udc89","\ud803\udcc9"],["\ud803\udc8a","\ud803\udcca"],["\ud803\udc8b","\ud803\udccb"],["\ud803\udc8c","\ud803\udccc"],["\ud803\udc8d","\ud803\udccd"],["\ud803\udc8e","\ud803\udcce"],["\ud803\udc8f","\ud803\udccf"],["\ud803\udc90","\ud803\udcd0"],["\ud803\udc91","\ud803\udcd1"],["\ud803\udc92","\ud803\udcd2"],["\ud803\udc93","\ud803\udcd3"],["\ud803\udc94","\ud803\udcd4"],["\ud803\udc95","\ud803\udcd5"],["\ud803\udc96","\ud803\udcd6"],["\ud803\udc97","\ud803\udcd7"],["\ud803\udc98","\ud803\udcd8"],["\ud803\udc99","\ud803\udcd9"],["\ud803\udc9a","\ud803\udcda"],["\ud803\udc9b","\ud803\udcdb"],["\ud803\udc9c","\ud803\udcdc"],["\ud803\udc9d","\ud803\udcdd"],["\ud803\udc9e","\ud803\udcde"],["\ud803\udc9f","\ud803\udcdf"],["\ud803\udca0","\ud803\udce0"],["\ud803\udca1","\ud803\udce1"],["\ud803\udca2","\ud803\udce2"],["\ud803\udca3","\ud803\udce3"],["\ud803\udca4","\ud803\udce4"],["\ud803\udca5","\ud803\udce5"],["\ud803\udca6","\ud803\udce6"],["\ud803\udca7","\ud803\udce7"],["\ud803\udca8","\ud803\udce8"],["\ud803\udca9","\ud803\udce9"],["\ud803\udcaa","\ud803\udcea"],["\ud803\udcab","\ud803\udceb"],["\ud803\udcac","\ud803\udcec"],["\ud803\udcad","\ud803\udced"],["\ud803\udcae","\ud803\udcee"],["\ud803\udcaf","\ud803\udcef"],["\ud803\udcb0","\ud803\udcf0"],["\ud803\udcb1","\ud803\udcf1"],["\ud803\udcb2","\ud803\udcf2"],["\ud803\udd24",""],["\ud803\udd25",""],["\ud803\udd26",""],["\ud803\udd27",""],["\ud803\udd50","\ud803\udd70"],["\ud803\udd51","\ud803\udd71"],["\ud803\udd52","\ud803\udd72"],["\ud803\udd53","\ud803\udd73"],["\ud803\udd54","\ud803\udd74"],["\ud803\udd55","\ud803\udd75"],["\ud803\udd56","\ud803\udd76"],["\ud803\udd57","\ud803\udd77"],["\ud803\udd58","\ud803\udd78"],["\ud803\udd59","\ud803\udd79"],["\ud803\udd5a","\ud803\udd7a"],["\ud803\udd5b","\ud803\udd7b"],["\ud803\udd5c","\ud803\udd7c"],["\ud803\udd5d","\ud803\udd7d"],["\ud803\udd5e","\ud803\udd7e"],["\ud803\udd5f","\ud803\udd7f"],["\ud803\udd60","\ud803\udd80"],["\ud803\udd61","\ud803\udd81"],["\ud803\udd62","\ud803\udd82"],["\ud803\udd63","\ud803\udd83"],["\ud803\udd64","\ud803\udd84"],["\ud803\udd65","\ud803\udd85"],["\ud803\udd69",""],["\ud803\udd6a",""],["\ud803\udd6b",""],["\ud803\udd6c",""],["\ud803\udd6d",""],["\ud803\udeab",""],["\ud803\udeac",""],["\ud803\udefd",""],["\ud803\udefe",""],["\ud803\udeff",""],["\ud803\udf46",""],["\ud803\udf47",""],["\ud803\udf48",""],["\ud803\udf49",""],["\ud803\udf4a",""],["\ud803\udf4b",""],["\ud803\udf4c",""],["\ud803\udf4d",""],["\ud803\udf4e",""],["\ud803\udf4f",""],["\ud803\udf50",""],["\ud803\udf82",""],["\ud803\udf83",""],["\ud803\udf84",""],["\ud803\udf85",""],["\ud804\udc46",""],["\ud804\udc70",""],["\ud804\udc7f",""],["\ud804\udc9a","\ud804\udc99"],["\ud804\udc9c","\ud804\udc9b"],["\ud804\udcab","\ud804\udca5"],["\ud804\udcb9",""],["\ud804\udcba",""],["\ud804\udd00",""],["\ud804\udd01",""],["\ud804\udd02",""],["\ud804\udd2e","\ud804\udd31\ud804\udd27"],["\ud804\udd2f","\ud804\udd32\ud804\udd27"],["\ud804\udd33",""],["\ud804\udd34",""],["\ud804\udd73",""],["\ud804\uddc0",""],["\ud804\uddca",""],["\ud804\ude35",""],["\ud804\ude36",""],["\ud804\udee9",""],["\ud804\udeea",""],["\ud804\udf3b",""],["\ud804\udf3c",""],["\ud804\udf4b","\ud804\udf47\ud804\udf3e"],["\ud804\udf4c","\ud804\udf47\ud804\udf57"],["\ud804\udf4d",""],["\ud804\udf66",""],["\ud804\udf67",""],["\ud804\udf68",""],["\ud804\udf69",""],["\ud804\udf6a",""],["\ud804\udf6b",""],["\ud804\udf6c",""],["\ud804\udf70",""],["\ud804\udf71",""],["\ud804\udf72",""],["\ud804\udf73",""],["\ud804\udf74",""],["\ud804\udf83","\ud804\udf82\ud804\udfc9"],["\ud804\udf85","\ud804\udf84\ud804\udfbb"],["\ud804\udf8e","\ud804\udf8b\ud804\udfc2"],["\ud804\udf91","\ud804\udf90\ud804\udfc9"],["\ud804\udfc5","\ud804\udfc2\ud804\udfc2"],["\ud804\udfc7","\ud804\udfc2\ud804\udfb8"],["\ud804\udfc8","\ud804\udfc2\ud804\udfc9"],["\ud804\udfce",""],["\ud804\udfcf",""],["\ud804\udfd0",""],["\ud805\udc42",""],["\ud805\udc46",""],["\ud805\udc5e",""],["\ud805\udcbb","\ud805\udcb9\ud805\udcba"],["\ud805\udcbc","\ud805\udcb9\ud805\udcb0"],["\ud805\udcbe","\ud805\udcb9\ud805\udcbd"],["\ud805\udcc2",""],["\ud805\udcc3",""],["\ud805\uddba","\ud805\uddb8\ud805\uddaf"],["\ud805\uddbb","\ud805\uddb9\ud805\uddaf"],["\ud805\uddbf",""],["\ud805\uddc0",""],["\ud805\ude3f",""],["\ud805\udeb6",""],["\ud805\udeb7",""],["\ud805\udf2b",""],["\ud806\udc39",""],["\ud806\udc3a",""],["\ud806\udca0","\ud806\udcc0"],["\ud806\udca1","\ud806\udcc1"],["\ud806\udca2","\ud806\udcc2"],["\ud806\udca3","\ud806\udcc3"],["\ud806\udca4","\ud806\udcc4"],["\ud806\udca5","\ud806\udcc5"],["\ud806\udca6","\ud806\udcc6"],["\ud806\udca7","\ud806\udcc7"],["\ud806\udca8","\ud806\udcc8"],["\ud806\udca9","\ud806\udcc9"],["\ud806\udcaa","\ud806\udcca"],["\ud806\udcab","\ud806\udccb"],["\ud806\udcac","\ud806\udccc"],["\ud806\udcad","\ud806\udccd"],["\ud806\udcae","\ud806\udcce"],["\ud806\udcaf","\ud806\udccf"],["\ud806\udcb0","\ud806\udcd0"],["\ud806\udcb1","\ud806\udcd1"],["\ud806\udcb2","\ud806\udcd2"],["\ud806\udcb3","\ud806\udcd3"],["\ud806\udcb4","\ud806\udcd4"],["\ud806\udcb5","\ud806\udcd5"],["\ud806\udcb6","\ud806\udcd6"],["\ud806\udcb7","\ud806\udcd7"],["\ud806\udcb8","\ud806\udcd8"],["\ud806\udcb9","\ud806\udcd9"],["\ud806\udcba","\ud806\udcda"],["\ud806\udcbb","\ud806\udcdb"],["\ud806\udcbc","\ud806\udcdc"],["\ud806\udcbd","\ud806\udcdd"],["\ud806\udcbe","\ud806\udcde"],["\ud806\udcbf","\ud806\udcdf"],["\ud806\udd38","\ud806\udd35\ud806\udd30"],["\ud806\udd3d",""],["\ud806\udd3e",""],["\ud806\udd43",""],["\ud806\udde0",""],["\ud806\ude34",""],["\ud806\ude47",""],["\ud806\ude99",""],["\ud807\udc3f",""],["\ud807\udd42",""],["\ud807\udd44",""],["\ud807\udd45",""],["\ud807\udd97",""],["\ud807\udf41",""],["\ud807\udf42",""],["\ud818\udd21","\ud818\udd1e\ud818\udd1e"],["\ud818\udd22","\ud818\udd1e\ud818\udd29"],["\ud818\udd23","\ud818\udd1e\ud818\udd1f"],["\ud818\udd24","\ud818\udd29\ud818\udd1f"],["\ud818\udd25","\ud818\udd1e\ud818\udd20"],["\ud818\udd26","\ud818\udd1e\ud818\udd1e\ud818\udd1f"],["\ud818\udd27","\ud818\udd1e\ud818\udd29\ud818\udd1f"],["\ud818\udd28","\ud818\udd1e\ud818\udd1e\ud818\udd20"],["\ud818\udd2f",""],["\ud81a\udef0",""],["\ud81a\udef1",""],["\ud81a\udef2",""],["\ud81a\udef3",""],["\ud81a\udef4",""],["\ud81a\udf30",""],["\ud81a\udf31",""],["\ud81a\udf32",""],["\ud81a\udf33",""],["\ud81a\udf34",""],["\ud81a\udf35",""],["\ud81a\udf36",""],["\ud81b\udd68","\ud81b\udd67\ud81b\udd67"],["\ud81b\udd69","\ud81b\udd63\ud81b\udd67"],["\ud81b\udd6a","\ud81b\udd63\ud81b\udd67\ud81b\udd67"],["\ud81b\ude40","\ud81b\ude60"],["\ud81b\ude41","\ud81b\ude61"],["\ud81b\ude42","\ud81b\ude62"],["\ud81b\ude43","\ud81b\ude63"],["\ud81b\ude44","\ud81b\ude64"],["\ud81b\ude45","\ud81b\ude65"],["\ud81b\ude46","\ud81b\ude66"],["\ud81b\ude47","\ud81b\ude67"],["\ud81b\ude48","\ud81b\ude68"],["\ud81b\ude49","\ud81b\ude69"],["\ud81b\ude4a","\ud81b\ude6a"],["\ud81b\ude4b","\ud81b\ude6b"],["\ud81b\ude4c","\ud81b\ude6c"],["\ud81b\ude4d","\ud81b\ude6d"],["\ud81b\ude4e","\ud81b\ude6e"],["\ud81b\ude4f","\ud81b\ude6f"],["\ud81b\ude50","\ud81b\ude70"],["\ud81b\ude51","\ud81b\ude71"],["\ud81b\ude52","\ud81b\ude72"],["\ud81b\ude53","\ud81b\ude73"],["\ud81b\ude54","\ud81b\ude74"],["\ud81b\ude55","\ud81b\ude75"],["\ud81b\ude56","\ud81b\ude76"],["\ud81b\ude57","\ud81b\ude77"],["\ud81b\ude58","\ud81b\ude78"],["\ud81b\ude59","\ud81b\ude79"],["\ud81b\ude5a","\ud81b\ude7a"],["\ud81b\ude5b","\ud81b\ude7b"],["\ud81b\ude5c","\ud81b\ude7c"],["\ud81b\ude5d","\ud81b\ude7d"],["\ud81b\ude5e","\ud81b\ude7e"],["\ud81b\ude5f","\ud81b\ude7f"],["\ud81b\udff0",""],["\ud81b\udff1",""],["\ud82f\udc9e",""],["\ud833\udcd6","a"],["\ud833\udcd7","b"],["\ud833\udcd8","c"],["\ud833\udcd9","d"],["\ud833\udcda","e"],["\ud833\udcdb","f"],["\ud833\udcdc","g"],["\ud833\udcdd","h"],["\ud833\udcde","i"],["\ud833\udcdf","j"],["\ud833\udce0","k"],["\ud833\udce1","l"],["\ud833\udce2","m"],["\ud833\udce3","n"],["\ud833\udce4","o"],["\ud833\udce5","p"],["\ud833\udce6","q"],["\ud833\udce7","r"],["\ud833\udce8","s"],["\ud833\udce9","t"],["\ud833\udcea","u"],["\ud833\udceb","v"],["\ud833\udcec","w"],["\ud833\udced","x"],["\ud833\udcee","y"],["\ud833\udcef","z"],["\ud833\udcf0","0"],["\ud833\udcf1","1"],["\ud833\udcf2","2"],["\ud833\udcf3","3"],["\ud833\udcf4","4"],["\ud833\udcf5","5"],["\ud833\udcf6","6"],["\ud833\udcf7","7"],["\ud833\udcf8","8"],["\ud833\udcf9","9"],["\ud834\udd5e","\ud834\udd57"],["\ud834\udd5f","\ud834\udd58"],["\ud834\udd60","\ud834\udd58"],["\ud834\udd61","\ud834\udd58"],["\ud834\udd62","\ud834\udd58"],["\ud834\udd63","\ud834\udd58"],["\ud834\udd64","\ud834\udd58"],["\ud834\udd65",""],["\ud834\udd66",""],["\ud834\udd67",""],["\ud834\udd68",""],["\ud834\udd69",""],["\ud834\udd6d",""],["\ud834\udd6e",""],["\ud834\udd6f",""],["\ud834\udd70",""],["\ud834\udd71",""],["\ud834\udd72",""],["\ud834\udd7b",""],["\ud834\udd7c",""],["\ud834\udd7d",""],["\ud834\udd7e",""],["\ud834\udd7f",""],["\ud834\udd80",""],["\ud834\udd81",""],["\ud834\udd82",""],["\ud834\udd85",""],["\ud834\udd86",""],["\ud834\udd87",""],["\ud834\udd88",""],["\ud834\udd89",""],["\ud834\udd8a",""],["\ud834\udd8b",""],["\ud834\uddaa",""],["\ud834\uddab",""],["\ud834\uddac",""],["\ud834\uddad",""],["\ud834\uddbb","\ud834\uddb9"],["\ud834\uddbc","\ud834\uddba"],["\ud834\uddbd","\ud834\uddb9"],["\ud834\uddbe","\ud834\uddba"],["\ud834\uddbf","\ud834\uddb9"],["\ud834\uddc0","\ud834\uddba"],["\ud834\ude42",""],["\ud834\ude43",""],["\ud834\ude44",""],["\ud835\udc00","a"],["\ud835\udc01","b"],["\ud835\udc02","c"],["\ud835\udc03","d"],["\ud835\udc04","e"],["\ud835\udc05","f"],["\ud835\udc06","g"],["\ud835\udc07","h"],["\ud835\udc08","i"],["\ud835\udc09","j"],["\ud835\udc0a","k"],["\ud835\udc0b","l"],["\ud835\udc0c","m"],["\ud835\udc0d","n"],["\ud835\udc0e","o"],["\ud835\udc0f","p"],["\ud835\udc10","q"],["\ud835\udc11","r"],["\ud835\udc12","s"],["\ud835\udc13","t"],["\ud835\udc14","u"],["\ud835\udc15","v"],["\ud835\udc16","w"],["\ud835\udc17","x"],["\ud835\udc18","y"],["\ud835\udc19","z"],["\ud835\udc1a","a"],["\ud835\udc1b","b"],["\ud835\udc1c","c"],["\ud835\udc1d","d"],["\ud835\udc1e","e"],["\ud835\udc1f","f"],["\ud835\udc20","g"],["\ud835\udc21","h"],["\ud835\udc22","i"],["\ud835\udc23","j"],["\ud835\udc24","k"],["\ud835\udc25","l"],["\ud835\udc26","m"],["\ud835\udc27","n"],["\ud835\udc28","o"],["\ud835\udc29","p"],["\ud835\udc2a","q"],["\ud835\udc2b","r"],["\ud835\udc2c","s"],["\ud835\udc2d","t"],["\ud835\udc2e","u"],["\ud835\udc2f","v"],["\ud835\udc30","w"],["\ud835\udc31","x"],["\ud835\udc32","y"],["\ud835\udc33","z"],["\ud835\udc34","a"],["\ud835\udc35","b"],["\ud835\udc36","c"],["\ud835\udc37","d"],["\ud835\udc38","e"],["\ud835\udc39","f"],["\ud835\udc3a","g"],["\ud835\udc3b","h"],["\ud835\udc3c","i"],["\ud835\udc3d","j"],["\ud835\udc3e","k"],["\ud835\udc3f","l"],["\ud835\udc40","m"],["\ud835\udc41","n"],["\ud835\udc42","o"],["\ud835\udc43","p"],["\ud835\udc44","q"],["\ud835\udc45","r"],["\ud835\udc46","s"],["\ud835\udc47","t"],["\ud835\udc48","u"],["\ud835\udc49","v"],["\ud835\udc4a","w"],["\ud835\udc4b","x"],["\ud835\udc4c","y"],["\ud835\udc4d","z"],["\ud835\udc4e","a"],["\ud835\udc4f","b"],["\ud835\udc50","c"],["\ud835\udc51","d"],["\ud835\udc52","e"],["\ud835\udc53","f"],["\ud835\udc54","g"],["\ud835\udc56","i"],["\ud835\udc57","j"],["\ud835\udc58","k"],["\ud835\udc59","l"],["\ud835\udc5a","m"],["\ud835\udc5b","n"],["\ud835\udc5c","o"],["\ud835\udc5d","p"],["\ud835\udc5e","q"],["\ud835\udc5f","r"],["\ud835\udc60","s"],["\ud835\udc61","t"],["\ud835\udc62","u"],["\ud835\udc63","v"],["\ud835\udc64","w"],["\ud835\udc65","x"],["\ud835\udc66","y"],["\ud835\udc67","z"],["\ud835\udc68","a"],["\ud835\udc69","b"],["\ud835\udc6a","c"],["\ud835\udc6b","d"],["\ud835\udc6c","e"],["\ud835\udc6d","f"],["\ud835\udc6e","g"],["\ud835\udc6f","h"],["\ud835\udc70","i"],["\ud835\udc71","j"],["\ud835\udc72","k"],["\ud835\udc73","l"],["\ud835\udc74","m"],["\ud835\udc75","n"],["\ud835\udc76","o"],["\ud835\udc77","p"],["\ud835\udc78","q"],["\ud835\udc79","r"],["\ud835\udc7a","s"],["\ud835\udc7b","t"],["\ud835\udc7c","u"],["\ud835\udc7d","v"],["\ud835\udc7e","w"],["\ud835\udc7f","x"],["\ud835\udc80","y"],["\ud835\udc81","z"],["\ud835\udc82","a"],["\ud835\udc83","b"],["\ud835\udc84","c"],["\ud835\udc85","d"],["\ud835\udc86","e"],["\ud835\udc87","f"],["\ud835\udc88","g"],["\ud835\udc89","h"],["\ud835\udc8a","i"],["\ud835\udc8b","j"],["\ud835\udc8c","k"],["\ud835\udc8d","l"],["\ud835\udc8e","m"],["\ud835\udc8f","n"],["\ud835\udc90","o"],["\ud835\udc91","p"],["\ud835\udc92","q"],["\ud835\udc93","r"],["\ud835\udc94","s"],["\ud835\udc95","t"],["\ud835\udc96","u"],["\ud835\udc97","v"],["\ud835\udc98","w"],["\ud835\udc99","x"],["\ud835\udc9a","y"],["\ud835\udc9b","z"],["\ud835\udc9c","a"],["\ud835\udc9e","c"],["\ud835\udc9f","d"],["\ud835\udca2","g"],["\ud835\udca5","j"],["\ud835\udca6","k"],["\ud835\udca9","n"],["\ud835\udcaa","o"],["\ud835\udcab","p"],["\ud835\udcac","q"],["\ud835\udcae","s"],["\ud835\udcaf","t"],["\ud835\udcb0","u"],["\ud835\udcb1","v"],["\ud835\udcb2","w"],["\ud835\udcb3","x"],["\ud835\udcb4","y"],["\ud835\udcb5","z"],["\ud835\udcb6","a"],["\ud835\udcb7","b"],["\ud835\udcb8","c"],["\ud835\udcb9","d"],["\ud835\udcbb","f"],["\ud835\udcbd","h"],["\ud835\udcbe","i"],["\ud835\udcbf","j"],["\ud835\udcc0","k"],["\ud835\udcc1","l"],["\ud835\udcc2","m"],["\ud835\udcc3","n"],["\ud835\udcc5","p"],["\ud835\udcc6","q"],["\ud835\udcc7","r"],["\ud835\udcc8","s"],["\ud835\udcc9","t"],["\ud835\udcca","u"],["\ud835\udccb","v"],["\ud835\udccc","w"],["\ud835\udccd","x"],["\ud835\udcce","y"],["\ud835\udccf","z"],["\ud835\udcd0","a"],["\ud835\udcd1","b"],["\ud835\udcd2","c"],["\ud835\udcd3","d"],["\ud835\udcd4","e"],["\ud835\udcd5","f"],["\ud835\udcd6","g"],["\ud835\udcd7","h"],["\ud835\udcd8","i"],["\ud835\udcd9","j"],["\ud835\udcda","k"],["\ud835\udcdb","l"],["\ud835\udcdc","m"],["\ud835\udcdd","n"],["\ud835\udcde","o"],["\ud835\udcdf","p"],["\ud835\udce0","q"],["\ud835\udce1","r"],["\ud835\udce2","s"],["\ud835\udce3","t"],["\ud835\udce4","u"],["\ud835\udce5","v"],["\ud835\udce6","w"],["\ud835\udce7","x"],["\ud835\udce8","y"],["\ud835\udce9","z"],["\ud835\udcea","a"],["\ud835\udceb","b"],["\ud835\udcec","c"],["\ud835\udced","d"],["\ud835\udcee","e"],["\ud835\udcef","f"],["\ud835\udcf0","g"],["\ud835\udcf1","h"],["\ud835\udcf2","i"],["\ud835\udcf3","j"],["\ud835\udcf4","k"],["\ud835\udcf5","l"],["\ud835\udcf6","m"],["\ud835\udcf7","n"],["\ud835\udcf8","o"],["\ud835\udcf9","p"],["\ud835\udcfa","q"],["\ud835\udcfb","r"],["\ud835\udcfc","s"],["\ud835\udcfd","t"],["\ud835\udcfe","u"],["\ud835\udcff","v"],["\ud835\udd00","w"],["\ud835\udd01","x"],["\ud835\udd02","y"],["\ud835\udd03","z"],["\ud835\udd04","a"],["\ud835\udd05","b"],["\ud835\udd07","d"],["\ud835\udd08","e"],["\ud835\udd09","f"],["\ud835\udd0a","g"],["\ud835\udd0d","j"],["\ud835\udd0e","k"],["\ud835\udd0f","l"],["\ud835\udd10","m"],["\ud835\udd11","n"],["\ud835\udd12","o"],["\ud835\udd13","p"],["\ud835\udd14","q"],["\ud835\udd16","s"],["\ud835\udd17","t"],["\ud835\udd18","u"],["\ud835\udd19","v"],["\ud835\udd1a","w"],["\ud835\udd1b","x"],["\ud835\udd1c","y"],["\ud835\udd1e","a"],["\ud835\udd1f","b"],["\ud835\udd20","c"],["\ud835\udd21","d"],["\ud835\udd22","e"],["\ud835\udd23","f"],["\ud835\udd24","g"],["\ud835\udd25","h"],["\ud835\udd26","i"],["\ud835\udd27","j"],["\ud835\udd28","k"],["\ud835\udd29","l"],["\ud835\udd2a","m"],["\ud835\udd2b","n"],["\ud835\udd2c","o"],["\ud835\udd2d","p"],["\ud835\udd2e","q"],["\ud835\udd2f","r"],["\ud835\udd30","s"],["\ud835\udd31","t"],["\ud835\udd32","u"],["\ud835\udd33","v"],["\ud835\udd34","w"],["\ud835\udd35","x"],["\ud835\udd36","y"],["\ud835\udd37","z"],["\ud835\udd38","a"],["\ud835\udd39","b"],["\ud835\udd3b","d"],["\ud835\udd3c","e"],["\ud835\udd3d","f"],["\ud835\udd3e","g"],["\ud835\udd40","i"],["\ud835\udd41","j"],["\ud835\udd42","k"],["\ud835\udd43","l"],["\ud835\udd44","m"],["\ud835\udd46","o"],["\ud835\udd4a","s"],["\ud835\udd4b","t"],["\ud835\udd4c","u"],["\ud835\udd4d","v"],["\ud835\udd4e","w"],["\ud835\udd4f","x"],["\ud835\udd50","y"],["\ud835\udd52","a"],["\ud835\udd53","b"],["\ud835\udd54","c"],["\ud835\udd55","d"],["\ud835\udd56","e"],["\ud835\udd57","f"],["\ud835\udd58","g"],["\ud835\udd59","h"],["\ud835\udd5a","i"],["\ud835\udd5b","j"],["\ud835\udd5c","k"],["\ud835\udd5d","l"],["\ud835\udd5e","m"],["\ud835\udd5f","n"],["\ud835\udd60","o"],["\ud835\udd61","p"],["\ud835\udd62","q"],["\ud835\udd63","r"],["\ud835\udd64","s"],["\ud835\udd65","t"],["\ud835\udd66","u"],["\ud835\udd67","v"],["\ud835\udd68","w"],["\ud835\udd69","x"],["\ud835\udd6a","y"],["\ud835\udd6b","z"],["\ud835\udd6c","a"],["\ud835\udd6d","b"],["\ud835\udd6e","c"],["\ud835\udd6f","d"],["\ud835\udd70","e"],["\ud835\udd71","f"],["\ud835\udd72","g"],["\ud835\udd73","h"],["\ud835\udd74","i"],["\ud835\udd75","j"],["\ud835\udd76","k"],["\ud835\udd77","l"],["\ud835\udd78","m"],["\ud835\udd79","n"],["\ud835\udd7a","o"],["\ud835\udd7b","p"],["\ud835\udd7c","q"],["\ud835\udd7d","r"],["\ud835\udd7e","s"],["\ud835\udd7f","t"],["\ud835\udd80","u"],["\ud835\udd81","v"],["\ud835\udd82","w"],["\ud835\udd83","x"],["\ud835\udd84","y"],["\ud835\udd85","z"],["\ud835\udd86","a"],["\ud835\udd87","b"],["\ud835\udd88","c"],["\ud835\udd89","d"],["\ud835\udd8a","e"],["\ud835\udd8b","f"],["\ud835\udd8c","g"],["\ud835\udd8d","h"],["\ud835\udd8e","i"],["\ud835\udd8f","j"],["\ud835\udd90","k"],["\ud835\udd91","l"],["\ud835\udd92","m"],["\ud835\udd93","n"],["\ud835\udd94","o"],["\ud835\udd95","p"],["\ud835\udd96","q"],["\ud835\udd97","r"],["\ud835\udd98","s"],["\ud835\udd99","t"],["\ud835\udd9a","u"],["\ud835\udd9b","v"],["\ud835\udd9c","w"],["\ud835\udd9d","x"],["\ud835\udd9e","y"],["\ud835\udd9f","z"],["\ud835\udda0","a"],["\ud835\udda1","b"],["\ud835\udda2","c"],["\ud835\udda3","d"],["\ud835\udda4","e"],["\ud835\udda5","f"],["\ud835\udda6","g"],["\ud835\udda7","h"],["\ud835\udda8","i"],["\ud835\udda9","j"],["\ud835\uddaa","k"],["\ud835\uddab","l"],["\ud835\uddac","m"],["\ud835\uddad","n"],["\ud835\uddae","o"],["\ud835\uddaf","p"],["\ud835\uddb0","q"],["\ud835\uddb1","r"],["\ud835\uddb2","s"],["\ud835\uddb3","t"],["\ud835\uddb4","u"],["\ud835\uddb5","v"],["\ud835\uddb6","w"],["\ud835\uddb7","x"],["\ud835\uddb8","y"],["\ud835\uddb9","z"],["\ud835\uddba","a"],["\ud835\uddbb","b"],["\ud835\uddbc","c"],["\ud835\uddbd","d"],["\ud835\uddbe","e"],["\ud835\uddbf","f"],["\ud835\uddc0","g"],["\ud835\uddc1","h"],["\ud835\uddc2","i"],["\ud835\uddc3","j"],["\ud835\uddc4","k"],["\ud835\uddc5","l"],["\ud835\uddc6","m"],["\ud835\uddc7","n"],["\ud835\uddc8","o"],["\ud835\uddc9","p"],["\ud835\uddca","q"],["\ud835\uddcb","r"],["\ud835\uddcc","s"],["\ud835\uddcd","t"],["\ud835\uddce","u"],["\ud835\uddcf","v"],["\ud835\uddd0","w"],["\ud835\uddd1","x"],["\ud835\uddd2","y"],["\ud835\uddd3","z"],["\ud835\uddd4","a"],["\ud835\uddd5","b"],["\ud835\uddd6","c"],["\ud835\uddd7","d"],["\ud835\uddd8","e"],["\ud835\uddd9","f"],["\ud835\uddda","g"],["\ud835\udddb","h"],["\ud835\udddc","i"],["\ud835\udddd","j"],["\ud835\uddde","k"],["\ud835\udddf","l"],["\ud835\udde0","m"],["\ud835\udde1","n"],["\ud835\udde2","o"],["\ud835\udde3","p"],["\ud835\udde4","q"],["\ud835\udde5","r"],["\ud835\udde6","s"],["\ud835\udde7","t"],["\ud835\udde8","u"],["\ud835\udde9","v"],["\ud835\uddea","w"],["\ud835\uddeb","x"],["\ud835\uddec","y"],["\ud835\udded","z"],["\ud835\uddee","a"],["\ud835\uddef","b"],["\ud835\uddf0","c"],["\ud835\uddf1","d"],["\ud835\uddf2","e"],["\ud835\uddf3","f"],["\ud835\uddf4","g"],["\ud835\uddf5","h"],["\ud835\uddf6","i"],["\ud835\uddf7","j"],["\ud835\uddf8","k"],["\ud835\uddf9","l"],["\ud835\uddfa","m"],["\ud835\uddfb","n"],["\ud835\uddfc","o"],["\ud835\uddfd","p"],["\ud835\uddfe","q"],["\ud835\uddff","r"],["\ud835\ude00","s"],["\ud835\ude01","t"],["\ud835\ude02","u"],["\ud835\ude03","v"],["\ud835\ude04","w"],["\ud835\ude05","x"],["\ud835\ude06","y"],["\ud835\ude07","z"],["\ud835\ude08","a"],["\ud835\ude09","b"],["\ud835\ude0a","c"],["\ud835\ude0b","d"],["\ud835\ude0c","e"],["\ud835\ude0d","f"],["\ud835\ude0e","g"],["\ud835\ude0f","h"],["\ud835\ude10","i"],["\ud835\ude11","j"],["\ud835\ude12","k"],["\ud835\ude13","l"],["\ud835\ude14","m"],["\ud835\ude15","n"],["\ud835\ude16","o"],["\ud835\ude17","p"],["\ud835\ude18","q"],["\ud835\ude19","r"],["\ud835\ude1a","s"],["\ud835\ude1b","t"],["\ud835\ude1c","u"],["\ud835\ude1d","v"],["\ud835\ude1e","w"],["\ud835\ude1f","x"],["\ud835\ude20","y"],["\ud835\ude21","z"],["\ud835\ude22","a"],["\ud835\ude23","b"],["\ud835\ude24","c"],["\ud835\ude25","d"],["\ud835\ude26","e"],["\ud835\ude27","f"],["\ud835\ude28","g"],["\ud835\ude29","h"],["\ud835\ude2a","i"],["\ud835\ude2b","j"],["\ud835\ude2c","k"],["\ud835\ude2d","l"],["\ud835\ude2e","m"],["\ud835\ude2f","n"],["\ud835\ude30","o"],["\ud835\ude31","p"],["\ud835\ude32","q"],["\ud835\ude33","r"],["\ud835\ude34","s"],["\ud835\ude35","t"],["\ud835\ude36","u"],["\ud835\ude37","v"],["\ud835\ude38","w"],["\ud835\ude39","x"],["\ud835\ude3a","y"],["\ud835\ude3b","z"],["\ud835\ude3c","a"],["\ud835\ude3d","b"],["\ud835\ude3e","c"],["\ud835\ude3f","d"],["\ud835\ude40","e"],["\ud835\ude41","f"],["\ud835\ude42","g"],["\ud835\ude43","h"],["\ud835\ude44","i"],["\ud835\ude45","j"],["\ud835\ude46","k"],["\ud835\ude47","l"],["\ud835\ude48","m"],["\ud835\ude49","n"],["\ud835\ude4a","o"],["\ud835\ude4b","p"],["\ud835\ude4c","q"],["\ud835\ude4d","r"],["\ud835\ude4e","s"],["\ud835\ude4f","t"],["\ud835\ude50","u"],["\ud835\ude51","v"],["\ud835\ude52","w"],["\ud835\ude53","x"],["\ud835\ude54","y"],["\ud835\ude55","z"],["\ud835\ude56","a"],["\ud835\ude57","b"],["\ud835\ude58","c"],["\ud835\ude59","d"],["\ud835\ude5a","e"],["\ud835\ude5b","f"],["\ud835\ude5c","g"],["\ud835\ude5d","h"],["\ud835\ude5e","i"],["\ud835\ude5f","j"],["\ud835\ude60","k"],["\ud835\ude61","l"],["\ud835\ude62","m"],["\ud835\ude63","n"],["\ud835\ude64","o"],["\ud835\ude65","p"],["\ud835\ude66","q"],["\ud835\ude67","r"],["\ud835\ude68","s"],["\ud835\ude69","t"],["\ud835\ude6a","u"],["\ud835\ude6b","v"],["\ud835\ude6c","w"],["\ud835\ude6d","x"],["\ud835\ude6e","y"],["\ud835\ude6f","z"],["\ud835\ude70","a"],["\ud835\ude71","b"],["\ud835\ude72","c"],["\ud835\ude73","d"],["\ud835\ude74","e"],["\ud835\ude75","f"],["\ud835\ude76","g"],["\ud835\ude77","h"],["\ud835\ude78","i"],["\ud835\ude79","j"],["\ud835\ude7a","k"],["\ud835\ude7b","l"],["\ud835\ude7c","m"],["\ud835\ude7d","n"],["\ud835\ude7e","o"],["\ud835\ude7f","p"],["\ud835\ude80","q"],["\ud835\ude81","r"],["\ud835\ude82","s"],["\ud835\ude83","t"],["\ud835\ude84","u"],["\ud835\ude85","v"],["\ud835\ude86","w"],["\ud835\ude87","x"],["\ud835\ude88","y"],["\ud835\ude89","z"],["\ud835\ude8a","a"],["\ud835\ude8b","b"],["\ud835\ude8c","c"],["\ud835\ude8d","d"],["\ud835\ude8e","e"],["\ud835\ude8f","f"],["\ud835\ude90","g"],["\ud835\ude91","h"],["\ud835\ude92","i"],["\ud835\ude93","j"],["\ud835\ude94","k"],["\ud835\ude95","l"],["\ud835\ude96","m"],["\ud835\ude97","n"],["\ud835\ude98","o"],["\ud835\ude99","p"],["\ud835\ude9a","q"],["\ud835\ude9b","r"],["\ud835\ude9c","s"],["\ud835\ude9d","t"],["\ud835\ude9e","u"],["\ud835\ude9f","v"],["\ud835\udea0","w"],["\ud835\udea1","x"],["\ud835\udea2","y"],["\ud835\udea3","z"],["\ud835\udea4","\u0131"],["\ud835\udea5","\u0237"],["\ud835\udea8","\u03b1"],["\ud835\udea9","\u03b2"],["\ud835\udeaa","\u03b3"],["\ud835\udeab","\u03b4"],["\ud835\udeac","\u03b5"],["\ud835\udead","\u03b6"],["\ud835\udeae","\u03b7"],["\ud835\udeaf","\u03b8"],["\ud835\udeb0","\u03b9"],["\ud835\udeb1","\u03ba"],["\ud835\udeb2","\u03bb"],["\ud835\udeb3","\u03bc"],["\ud835\udeb4","\u03bd"],["\ud835\udeb5","\u03be"],["\ud835\udeb6","\u03bf"],["\ud835\udeb7","\u03c0"],["\ud835\udeb8","\u03c1"],["\ud835\udeb9","\u03b8"],["\ud835\udeba","\u03c3"],["\ud835\udebb","\u03c4"],["\ud835\udebc","\u03c5"],["\ud835\udebd","\u03c6"],["\ud835\udebe","\u03c7"],["\ud835\udebf","\u03c8"],["\ud835\udec0","\u03c9"],["\ud835\udec1","\u2207"],["\ud835\udec2","\u03b1"],["\ud835\udec3","\u03b2"],["\ud835\udec4","\u03b3"],["\ud835\udec5","\u03b4"],["\ud835\udec6","\u03b5"],["\ud835\udec7","\u03b6"],["\ud835\udec8","\u03b7"],["\ud835\udec9","\u03b8"],["\ud835\udeca","\u03b9"],["\ud835\udecb","\u03ba"],["\ud835\udecc","\u03bb"],["\ud835\udecd","\u03bc"],["\ud835\udece","\u03bd"],["\ud835\udecf","\u03be"],["\ud835\uded0","\u03bf"],["\ud835\uded1","\u03c0"],["\ud835\uded2","\u03c1"],["\ud835\uded3","\u03c3"],["\ud835\uded4","\u03c3"],["\ud835\uded5","\u03c4"],["\ud835\uded6","\u03c5"],["\ud835\uded7","\u03c6"],["\ud835\uded8","\u03c7"],["\ud835\uded9","\u03c8"],["\ud835\udeda","\u03c9"],["\ud835\udedb","\u2202"],["\ud835\udedc","\u03b5"],["\ud835\udedd","\u03b8"],["\ud835\udede","\u03ba"],["\ud835\udedf","\u03c6"],["\ud835\udee0","\u03c1"],["\ud835\udee1","\u03c0"],["\ud835\udee2","\u03b1"],["\ud835\udee3","\u03b2"],["\ud835\udee4","\u03b3"],["\ud835\udee5","\u03b4"],["\ud835\udee6","\u03b5"],["\ud835\udee7","\u03b6"],["\ud835\udee8","\u03b7"],["\ud835\udee9","\u03b8"],["\ud835\udeea","\u03b9"],["\ud835\udeeb","\u03ba"],["\ud835\udeec","\u03bb"],["\ud835\udeed","\u03bc"],["\ud835\udeee","\u03bd"],["\ud835\udeef","\u03be"],["\ud835\udef0","\u03bf"],["\ud835\udef1","\u03c0"],["\ud835\udef2","\u03c1"],["\ud835\udef3","\u03b8"],["\ud835\udef4","\u03c3"],["\ud835\udef5","\u03c4"],["\ud835\udef6","\u03c5"],["\ud835\udef7","\u03c6"],["\ud835\udef8","\u03c7"],["\ud835\udef9","\u03c8"],["\ud835\udefa","\u03c9"],["\ud835\udefb","\u2207"],["\ud835\udefc","\u03b1"],["\ud835\udefd","\u03b2"],["\ud835\udefe","\u03b3"],["\ud835\udeff","\u03b4"],["\ud835\udf00","\u03b5"],["\ud835\udf01","\u03b6"],["\ud835\udf02","\u03b7"],["\ud835\udf03","\u03b8"],["\ud835\udf04","\u03b9"],["\ud835\udf05","\u03ba"],["\ud835\udf06","\u03bb"],["\ud835\udf07","\u03bc"],["\ud835\udf08","\u03bd"],["\ud835\udf09","\u03be"],["\ud835\udf0a","\u03bf"],["\ud835\udf0b","\u03c0"],["\ud835\udf0c","\u03c1"],["\ud835\udf0d","\u03c3"],["\ud835\udf0e","\u03c3"],["\ud835\udf0f","\u03c4"],["\ud835\udf10","\u03c5"],["\ud835\udf11","\u03c6"],["\ud835\udf12","\u03c7"],["\ud835\udf13","\u03c8"],["\ud835\udf14","\u03c9"],["\ud835\udf15","\u2202"],["\ud835\udf16","\u03b5"],["\ud835\udf17","\u03b8"],["\ud835\udf18","\u03ba"],["\ud835\udf19","\u03c6"],["\ud835\udf1a","\u03c1"],["\ud835\udf1b","\u03c0"],["\ud835\udf1c","\u03b1"],["\ud835\udf1d","\u03b2"],["\ud835\udf1e","\u03b3"],["\ud835\udf1f","\u03b4"],["\ud835\udf20","\u03b5"],["\ud835\udf21","\u03b6"],["\ud835\udf22","\u03b7"],["\ud835\udf23","\u03b8"],["\ud835\udf24","\u03b9"],["\ud835\udf25","\u03ba"],["\ud835\udf26","\u03bb"],["\ud835\udf27","\u03bc"],["\ud835\udf28","\u03bd"],["\ud835\udf29","\u03be"],["\ud835\udf2a","\u03bf"],["\ud835\udf2b","\u03c0"],["\ud835\udf2c","\u03c1"],["\ud835\udf2d","\u03b8"],["\ud835\udf2e","\u03c3"],["\ud835\udf2f","\u03c4"],["\ud835\udf30","\u03c5"],["\ud835\udf31","\u03c6"],["\ud835\udf32","\u03c7"],["\ud835\udf33","\u03c8"],["\ud835\udf34","\u03c9"],["\ud835\udf35","\u2207"],["\ud835\udf36","\u03b1"],["\ud835\udf37","\u03b2"],["\ud835\udf38","\u03b3"],["\ud835\udf39","\u03b4"],["\ud835\udf3a","\u03b5"],["\ud835\udf3b","\u03b6"],["\ud835\udf3c","\u03b7"],["\ud835\udf3d","\u03b8"],["\ud835\udf3e","\u03b9"],["\ud835\udf3f","\u03ba"],["\ud835\udf40","\u03bb"],["\ud835\udf41","\u03bc"],["\ud835\udf42","\u03bd"],["\ud835\udf43","\u03be"],["\ud835\udf44","\u03bf"],["\ud835\udf45","\u03c0"],["\ud835\udf46","\u03c1"],["\ud835\udf47","\u03c3"],["\ud835\udf48","\u03c3"],["\ud835\udf49","\u03c4"],["\ud835\udf4a","\u03c5"],["\ud835\udf4b","\u03c6"],["\ud835\udf4c","\u03c7"],["\ud835\udf4d","\u03c8"],["\ud835\udf4e","\u03c9"],["\ud835\udf4f","\u2202"],["\ud835\udf50","\u03b5"],["\ud835\udf51","\u03b8"],["\ud835\udf52","\u03ba"],["\ud835\udf53","\u03c6"],["\ud835\udf54","\u03c1"],["\ud835\udf55","\u03c0"],["\ud835\udf56","\u03b1"],["\ud835\udf57","\u03b2"],["\ud835\udf58","\u03b3"],["\ud835\udf59","\u03b4"],["\ud835\udf5a","\u03b5"],["\ud835\udf5b","\u03b6"],["\ud835\udf5c","\u03b7"],["\ud835\udf5d","\u03b8"],["\ud835\udf5e","\u03b9"],["\ud835\udf5f","\u03ba"],["\ud835\udf60","\u03bb"],["\ud835\udf61","\u03bc"],["\ud835\udf62","\u03bd"],["\ud835\udf63","\u03be"],["\ud835\udf64","\u03bf"],["\ud835\udf65","\u03c0"],["\ud835\udf66","\u03c1"],["\ud835\udf67","\u03b8"],["\ud835\udf68","\u03c3"],["\ud835\udf69","\u03c4"],["\ud835\udf6a","\u03c5"],["\ud835\udf6b","\u03c6"],["\ud835\udf6c","\u03c7"],["\ud835\udf6d","\u03c8"],["\ud835\udf6e","\u03c9"],["\ud835\udf6f","\u2207"],["\ud835\udf70","\u03b1"],["\ud835\udf71","\u03b2"],["\ud835\udf72","\u03b3"],["\ud835\udf73","\u03b4"],["\ud835\udf74","\u03b5"],["\ud835\udf75","\u03b6"],["\ud835\udf76","\u03b7"],["\ud835\udf77","\u03b8"],["\ud835\udf78","\u03b9"],["\ud835\udf79","\u03ba"],["\ud835\udf7a","\u03bb"],["\ud835\udf7b","\u03bc"],["\ud835\udf7c","\u03bd"],["\ud835\udf7d","\u03be"],["\ud835\udf7e","\u03bf"],["\ud835\udf7f","\u03c0"],["\ud835\udf80","\u03c1"],["\ud835\udf81","\u03c3"],["\ud835\udf82","\u03c3"],["\ud835\udf83","\u03c4"],["\ud835\udf84","\u03c5"],["\ud835\udf85","\u03c6"],["\ud835\udf86","\u03c7"],["\ud835\udf87","\u03c8"],["\ud835\udf88","\u03c9"],["\ud835\udf89","\u2202"],["\ud835\udf8a","\u03b5"],["\ud835\udf8b","\u03b8"],["\ud835\udf8c","\u03ba"],["\ud835\udf8d","\u03c6"],["\ud835\udf8e","\u03c1"],["\ud835\udf8f","\u03c0"],["\ud835\udf90","\u03b1"],["\ud835\udf91","\u03b2"],["\ud835\udf92","\u03b3"],["\ud835\udf93","\u03b4"],["\ud835\udf94","\u03b5"],["\ud835\udf95","\u03b6"],["\ud835\udf96","\u03b7"],["\ud835\udf97","\u03b8"],["\ud835\udf98","\u03b9"],["\ud835\udf99","\u03ba"],["\ud835\udf9a","\u03bb"],["\ud835\udf9b","\u03bc"],["\ud835\udf9c","\u03bd"],["\ud835\udf9d","\u03be"],["\ud835\udf9e","\u03bf"],["\ud835\udf9f","\u03c0"],["\ud835\udfa0","\u03c1"],["\ud835\udfa1","\u03b8"],["\ud835\udfa2","\u03c3"],["\ud835\udfa3","\u03c4"],["\ud835\udfa4","\u03c5"],["\ud835\udfa5","\u03c6"],["\ud835\udfa6","\u03c7"],["\ud835\udfa7","\u03c8"],["\ud835\udfa8","\u03c9"],["\ud835\udfa9","\u2207"],["\ud835\udfaa","\u03b1"],["\ud835\udfab","\u03b2"],["\ud835\udfac","\u03b3"],["\ud835\udfad","\u03b4"],["\ud835\udfae","\u03b5"],["\ud835\udfaf","\u03b6"],["\ud835\udfb0","\u03b7"],["\ud835\udfb1","\u03b8"],["\ud835\udfb2","\u03b9"],["\ud835\udfb3","\u03ba"],["\ud835\udfb4","\u03bb"],["\ud835\udfb5","\u03bc"],["\ud835\udfb6","\u03bd"],["\ud835\udfb7","\u03be"],["\ud835\udfb8","\u03bf"],["\ud835\udfb9","\u03c0"],["\ud835\udfba","\u03c1"],["\ud835\udfbb","\u03c3"],["\ud835\udfbc","\u03c3"],["\ud835\udfbd","\u03c4"],["\ud835\udfbe","\u03c5"],["\ud835\udfbf","\u03c6"],["\ud835\udfc0","\u03c7"],["\ud835\udfc1","\u03c8"],["\ud835\udfc2","\u03c9"],["\ud835\udfc3","\u2202"],["\ud835\udfc4","\u03b5"],["\ud835\udfc5","\u03b8"],["\ud835\udfc6","\u03ba"],["\ud835\udfc7","\u03c6"],["\ud835\udfc8","\u03c1"],["\ud835\udfc9","\u03c0"],["\ud835\udfca","\u03dd"],["\ud835\udfcb","\u03dd"],["\ud835\udfce","0"],["\ud835\udfcf","1"],["\ud835\udfd0","2"],["\ud835\udfd1","3"],["\ud835\udfd2","4"],["\ud835\udfd3","5"],["\ud835\udfd4","6"],["\ud835\udfd5","7"],["\ud835\udfd6","8"],["\ud835\udfd7","9"],["\ud835\udfd8","0"],["\ud835\udfd9","1"],["\ud835\udfda","2"],["\ud835\udfdb","3"],["\ud835\udfdc","4"],["\ud835\udfdd","5"],["\ud835\udfde","6"],["\ud835\udfdf","7"],["\ud835\udfe0","8"],["\ud835\udfe1","9"],["\ud835\udfe2","0"],["\ud835\udfe3","1"],["\ud835\udfe4","2"],["\ud835\udfe5","3"],["\ud835\udfe6","4"],["\ud835\udfe7","5"],["\ud835\udfe8","6"],["\ud835\udfe9","7"],["\ud835\udfea","8"],["\ud835\udfeb","9"],["\ud835\udfec","0"],["\ud835\udfed","1"],["\ud835\udfee","2"],["\ud835\udfef","3"],["\ud835\udff0","4"],["\ud835\udff1","5"],["\ud835\udff2","6"],["\ud835\udff3","7"],["\ud835\udff4","8"],["\ud835\udff5","9"],["\ud835\udff6","0"],["\ud835\udff7","1"],["\ud835\udff8","2"],["\ud835\udff9","3"],["\ud835\udffa","4"],["\ud835\udffb","5"],["\ud835\udffc","6"],["\ud835\udffd","7"],["\ud835\udffe","8"],["\ud835\udfff","9"],["\ud838\udc00",""],["\ud838\udc01",""],["\ud838\udc02",""],["\ud838\udc03",""],["\ud838\udc04",""],["\ud838\udc05",""],["\ud838\udc06",""],["\ud838\udc08",""],["\ud838\udc09",""],["\ud838\udc0a",""],["\ud838\udc0b",""],["\ud838\udc0c",""],["\ud838\udc0d",""],["\ud838\udc0e",""],["\ud838\udc0f",""],["\ud838\udc10",""],["\ud838\udc11",""],["\ud838\udc12",""],["\ud838\udc13",""],["\ud838\udc14",""],["\ud838\udc15",""],["\ud838\udc16",""],["\ud838\udc17",""],["\ud838\udc18",""],["\ud838\udc1b",""],["\ud838\udc1c",""],["\ud838\udc1d",""],["\ud838\udc1e",""],["\ud838\udc1f",""],["\ud838\udc20",""],["\ud838\udc21",""],["\ud838\udc23",""],["\ud838\udc24",""],["\ud838\udc26",""],["\ud838\udc27",""],["\ud838\udc28",""],["\ud838\udc29",""],["\ud838\udc2a",""],["\ud838\udc30","\u0430"],["\ud838\udc31","\u0431"],["\ud838\udc32","\u0432"],["\ud838\udc33","\u0433"],["\ud838\udc34","\u0434"],["\ud838\udc35","\u0435"],["\ud838\udc36","\u0436"],["\ud838\udc37","\u0437"],["\ud838\udc38","\u0438"],["\ud838\udc39","\u043a"],["\ud838\udc3a","\u043b"],["\ud838\udc3b","\u043c"],["\ud838\udc3c","\u043e"],["\ud838\udc3d","\u043f"],["\ud838\udc3e","\u0440"],["\ud838\udc3f","\u0441"],["\ud838\udc40","\u0442"],["\ud838\udc41","\u0443"],["\ud838\udc42","\u0444"],["\ud838\udc43","\u0445"],["\ud838\udc44","\u0446"],["\ud838\udc45","\u0447"],["\ud838\udc46","\u0448"],["\ud838\udc47","\u044b"],["\ud838\udc48","\u044d"],["\ud838\udc49","\u044e"],["\ud838\udc4a","\ua689"],["\ud838\udc4b","\u04d9"],["\ud838\udc4c","\u0456"],["\ud838\udc4d","\u0458"],["\ud838\udc4e","\u04e9"],["\ud838\udc4f","\u04af"],["\ud838\udc50","\u04cf"],["\ud838\udc51","\u0430"],["\ud838\udc52","\u0431"],["\ud838\udc53","\u0432"],["\ud838\udc54","\u0433"],["\ud838\udc55","\u0434"],["\ud838\udc56","\u0435"],["\ud838\udc57","\u0436"],["\ud838\udc58","\u0437"],["\ud838\udc59","\u0438"],["\ud838\udc5a","\u043a"],["\ud838\udc5b","\u043b"],["\ud838\udc5c","\u043e"],["\ud838\udc5d","\u043f"],["\ud838\udc5e","\u0441"],["\ud838\udc5f","\u0443"],["\ud838\udc60","\u0444"],["\ud838\udc61","\u0445"],["\ud838\udc62","\u0446"],["\ud838\udc63","\u0447"],["\ud838\udc64","\u0448"],["\ud838\udc65","\u044a"],["\ud838\udc66","\u044b"],["\ud838\udc67","\u0491"],["\ud838\udc68","\u0456"],["\ud838\udc69","\u0455"],["\ud838\udc6a","\u045f"],["\ud838\udc6b","\u04ab"],["\ud838\udc6c","\ua651"],["\ud838\udc6d","\u04b1"],["\ud838\udc8f",""],["\ud838\udd30",""],["\ud838\udd31",""],["\ud838\udd32",""],["\ud838\udd33",""],["\ud838\udd34",""],["\ud838\udd35",""],["\ud838\udd36",""],["\ud838\udeae",""],["\ud838\udeec",""],["\ud838\udeed",""],["\ud838\udeee",""],["\ud838\udeef",""],["\ud839\udcec",""],["\ud839\udced",""],["\ud839\udcee",""],["\ud839\udcef",""],["\ud839\uddee",""],["\ud839\uddef",""],["\ud83a\udcd0",""],["\ud83a\udcd1",""],["\ud83a\udcd2",""],["\ud83a\udcd3",""],["\ud83a\udcd4",""],["\ud83a\udcd5",""],["\ud83a\udcd6",""],["\ud83a\udd00","\ud83a\udd22"],["\ud83a\udd01","\ud83a\udd23"],["\ud83a\udd02","\ud83a\udd24"],["\ud83a\udd03","\ud83a\udd25"],["\ud83a\udd04","\ud83a\udd26"],["\ud83a\udd05","\ud83a\udd27"],["\ud83a\udd06","\ud83a\udd28"],["\ud83a\udd07","\ud83a\udd29"],["\ud83a\udd08","\ud83a\udd2a"],["\ud83a\udd09","\ud83a\udd2b"],["\ud83a\udd0a","\ud83a\udd2c"],["\ud83a\udd0b","\ud83a\udd2d"],["\ud83a\udd0c","\ud83a\udd2e"],["\ud83a\udd0d","\ud83a\udd2f"],["\ud83a\udd0e","\ud83a\udd30"],["\ud83a\udd0f","\ud83a\udd31"],["\ud83a\udd10","\ud83a\udd32"],["\ud83a\udd11","\ud83a\udd33"],["\ud83a\udd12","\ud83a\udd34"],["\ud83a\udd13","\ud83a\udd35"],["\ud83a\udd14","\ud83a\udd36"],["\ud83a\udd15","\ud83a\udd37"],["\ud83a\udd16","\ud83a\udd38"],["\ud83a\udd17","\ud83a\udd39"],["\ud83a\udd18","\ud83a\udd3a"],["\ud83a\udd19","\ud83a\udd3b"],["\ud83a\udd1a","\ud83a\udd3c"],["\ud83a\udd1b","\ud83a\udd3d"],["\ud83a\udd1c","\ud83a\udd3e"],["\ud83a\udd1d","\ud83a\udd3f"],["\ud83a\udd1e","\ud83a\udd40"],["\ud83a\udd1f","\ud83a\udd41"],["\ud83a\udd20","\ud83a\udd42"],["\ud83a\udd21","\ud83a\udd43"],["\ud83a\udd44",""],["\ud83a\udd45",""],["\ud83a\udd46",""],["\ud83a\udd47",""],["\ud83a\udd48",""],["\ud83a\udd49",""],["\ud83a\udd4a",""],["\ud83b\ude00","\u0627"],["\ud83b\ude01","\u0628"],["\ud83b\ude02","\u062c"],["\ud83b\ude03","\u062f"],["\ud83b\ude05","\u0648"],["\ud83b\ude06","\u0632"],["\ud83b\ude07","\u062d"],["\ud83b\ude08","\u0637"],["\ud83b\ude09","\u064a"],["\ud83b\ude0a","\u0643"],["\ud83b\ude0b","\u0644"],["\ud83b\ude0c","\u0645"],["\ud83b\ude0d","\u0646"],["\ud83b\ude0e","\u0633"],["\ud83b\ude0f","\u0639"],["\ud83b\ude10","\u0641"],["\ud83b\ude11","\u0635"],["\ud83b\ude12","\u0642"],["\ud83b\ude13","\u0631"],["\ud83b\ude14","\u0634"],["\ud83b\ude15","\u062a"],["\ud83b\ude16","\u062b"],["\ud83b\ude17","\u062e"],["\ud83b\ude18","\u0630"],["\ud83b\ude19","\u0636"],["\ud83b\ude1a","\u0638"],["\ud83b\ude1b","\u063a"],["\ud83b\ude1c","\u066e"],["\ud83b\ude1d","\u06ba"],["\ud83b\ude1e","\u06a1"],["\ud83b\ude1f","\u066f"],["\ud83b\ude21","\u0628"],["\ud83b\ude22","\u062c"],["\ud83b\ude24","\u0647"],["\ud83b\ude27","\u062d"],["\ud83b\ude29","\u064a"],["\ud83b\ude2a","\u0643"],["\ud83b\ude2b","\u0644"],["\ud83b\ude2c","\u0645"],["\ud83b\ude2d","\u0646"],["\ud83b\ude2e","\u0633"],["\ud83b\ude2f","\u0639"],["\ud83b\ude30","\u0641"],["\ud83b\ude31","\u0635"],["\ud83b\ude32","\u0642"],["\ud83b\ude34","\u0634"],["\ud83b\ude35","\u062a"],["\ud83b\ude36","\u062b"],["\ud83b\ude37","\u062e"],["\ud83b\ude39","\u0636"],["\ud83b\ude3b","\u063a"],["\ud83b\ude42","\u062c"],["\ud83b\ude47","\u062d"],["\ud83b\ude49","\u064a"],["\ud83b\ude4b","\u0644"],["\ud83b\ude4d","\u0646"],["\ud83b\ude4e","\u0633"],["\ud83b\ude4f","\u0639"],["\ud83b\ude51","\u0635"],["\ud83b\ude52","\u0642"],["\ud83b\ude54","\u0634"],["\ud83b\ude57","\u062e"],["\ud83b\ude59","\u0636"],["\ud83b\ude5b","\u063a"],["\ud83b\ude5d","\u06ba"],["\ud83b\ude5f","\u066f"],["\ud83b\ude61","\u0628"],["\ud83b\ude62","\u062c"],["\ud83b\ude64","\u0647"],["\ud83b\ude67","\u062d"],["\ud83b\ude68","\u0637"],["\ud83b\ude69","\u064a"],["\ud83b\ude6a","\u0643"],["\ud83b\ude6c","\u0645"],["\ud83b\ude6d","\u0646"],["\ud83b\ude6e","\u0633"],["\ud83b\ude6f","\u0639"],["\ud83b\ude70","\u0641"],["\ud83b\ude71","\u0635"],["\ud83b\ude72","\u0642"],["\ud83b\ude74","\u0634"],["\ud83b\ude75","\u062a"],["\ud83b\ude76","\u062b"],["\ud83b\ude77","\u062e"],["\ud83b\ude79","\u0636"],["\ud83b\ude7a","\u0638"],["\ud83b\ude7b","\u063a"],["\ud83b\ude7c","\u066e"],["\ud83b\ude7e","\u06a1"],["\ud83b\ude80","\u0627"],["\ud83b\ude81","\u0628"],["\ud83b\ude82","\u062c"],["\ud83b\ude83","\u062f"],["\ud83b\ude84","\u0647"],["\ud83b\ude85","\u0648"],["\ud83b\ude86","\u0632"],["\ud83b\ude87","\u062d"],["\ud83b\ude88","\u0637"],["\ud83b\ude89","\u064a"],["\ud83b\ude8b","\u0644"],["\ud83b\ude8c","\u0645"],["\ud83b\ude8d","\u0646"],["\ud83b\ude8e","\u0633"],["\ud83b\ude8f","\u0639"],["\ud83b\ude90","\u0641"],["\ud83b\ude91","\u0635"],["\ud83b\ude92","\u0642"],["\ud83b\ude93","\u0631"],["\ud83b\ude94","\u0634"],["\ud83b\ude95","\u062a"],["\ud83b\ude96","\u062b"],["\ud83b\ude97","\u062e"],["\ud83b\ude98","\u0630"],["\ud83b\ude99","\u0636"],["\ud83b\ude9a","\u0638"],["\ud83b\ude9b","\u063a"],["\ud83b\udea1","\u0628"],["\ud83b\udea2","\u062c"],["\ud83b\udea3","\u062f"],["\ud83b\udea5","\u0648"],["\ud83b\udea6","\u0632"],["\ud83b\udea7","\u062d"],["\ud83b\udea8","\u0637"],["\ud83b\udea9","\u064a"],["\ud83b\udeab","\u0644"],["\ud83b\udeac","\u0645"],["\ud83b\udead","\u0646"],["\ud83b\udeae","\u0633"],["\ud83b\udeaf","\u0639"],["\ud83b\udeb0","\u0641"],["\ud83b\udeb1","\u0635"],["\ud83b\udeb2","\u0642"],["\ud83b\udeb3","\u0631"],["\ud83b\udeb4","\u0634"],["\ud83b\udeb5","\u062a"],["\ud83b\udeb6","\u062b"],["\ud83b\udeb7","\u062e"],["\ud83b\udeb8","\u0630"],["\ud83b\udeb9","\u0636"],["\ud83b\udeba","\u0638"],["\ud83b\udebb","\u063a"],["\ud83c\udd00","0."],["\ud83c\udd01","0,"],["\ud83c\udd02","1,"],["\ud83c\udd03","2,"],["\ud83c\udd04","3,"],["\ud83c\udd05","4,"],["\ud83c\udd06","5,"],["\ud83c\udd07","6,"],["\ud83c\udd08","7,"],["\ud83c\udd09","8,"],["\ud83c\udd0a","9,"],["\ud83c\udd10","(a)"],["\ud83c\udd11","(b)"],["\ud83c\udd12","(c)"],["\ud83c\udd13","(d)"],["\ud83c\udd14","(e)"],["\ud83c\udd15","(f)"],["\ud83c\udd16","(g)"],["\ud83c\udd17","(h)"],["\ud83c\udd18","(i)"],["\ud83c\udd19","(j)"],["\ud83c\udd1a","(k)"],["\ud83c\udd1b","(l)"],["\ud83c\udd1c","(m)"],["\ud83c\udd1d","(n)"],["\ud83c\udd1e","(o)"],["\ud83c\udd1f","(p)"],["\ud83c\udd20","(q)"],["\ud83c\udd21","(r)"],["\ud83c\udd22","(s)"],["\ud83c\udd23","(t)"],["\ud83c\udd24","(u)"],["\ud83c\udd25","(v)"],["\ud83c\udd26","(w)"],["\ud83c\udd27","(x)"],["\ud83c\udd28","(y)"],["\ud83c\udd29","(z)"],["\ud83c\udd2a","\u3014s\u3015"],["\ud83c\udd2b","c"],["\ud83c\udd2c","r"],["\ud83c\udd2d","cd"],["\ud83c\udd2e","wz"],["\ud83c\udd30","a"],["\ud83c\udd31","b"],["\ud83c\udd32","c"],["\ud83c\udd33","d"],["\ud83c\udd34","e"],["\ud83c\udd35","f"],["\ud83c\udd36","g"],["\ud83c\udd37","h"],["\ud83c\udd38","i"],["\ud83c\udd39","j"],["\ud83c\udd3a","k"],["\ud83c\udd3b","l"],["\ud83c\udd3c","m"],["\ud83c\udd3d","n"],["\ud83c\udd3e","o"],["\ud83c\udd3f","p"],["\ud83c\udd40","q"],["\ud83c\udd41","r"],["\ud83c\udd42","s"],["\ud83c\udd43","t"],["\ud83c\udd44","u"],["\ud83c\udd45","v"],["\ud83c\udd46","w"],["\ud83c\udd47","x"],["\ud83c\udd48","y"],["\ud83c\udd49","z"],["\ud83c\udd4a","hv"],["\ud83c\udd4b","mv"],["\ud83c\udd4c","sd"],["\ud83c\udd4d","ss"],["\ud83c\udd4e","ppv"],["\ud83c\udd4f","wc"],["\ud83c\udd6a","mc"],["\ud83c\udd6b","md"],["\ud83c\udd6c","mr"],["\ud83c\udd90","dj"],["\ud83c\ude00","\u307b\u304b"],["\ud83c\ude01","\u30b3\u30b3"],["\ud83c\ude02","\u30b5"],["\ud83c\ude10","\u624b"],["\ud83c\ude11","\u5b57"],["\ud83c\ude12","\u53cc"],["\ud83c\ude13","\u30c6"],["\ud83c\ude14","\u4e8c"],["\ud83c\ude15","\u591a"],["\ud83c\ude16","\u89e3"],["\ud83c\ude17","\u5929"],["\ud83c\ude18","\u4ea4"],["\ud83c\ude19","\u6620"],["\ud83c\ude1a","\u7121"],["\ud83c\ude1b","\u6599"],["\ud83c\ude1c","\u524d"],["\ud83c\ude1d","\u5f8c"],["\ud83c\ude1e","\u518d"],["\ud83c\ude1f","\u65b0"],["\ud83c\ude20","\u521d"],["\ud83c\ude21","\u7d42"],["\ud83c\ude22","\u751f"],["\ud83c\ude23","\u8ca9"],["\ud83c\ude24","\u58f0"],["\ud83c\ude25","\u5439"],["\ud83c\ude26","\u6f14"],["\ud83c\ude27","\u6295"],["\ud83c\ude28","\u6355"],["\ud83c\ude29","\u4e00"],["\ud83c\ude2a","\u4e09"],["\ud83c\ude2b","\u904a"],["\ud83c\ude2c","\u5de6"],["\ud83c\ude2d","\u4e2d"],["\ud83c\ude2e","\u53f3"],["\ud83c\ude2f","\u6307"],["\ud83c\ude30","\u8d70"],["\ud83c\ude31","\u6253"],["\ud83c\ude32","\u7981"],["\ud83c\ude33","\u7a7a"],["\ud83c\ude34","\u5408"],["\ud83c\ude35","\u6e80"],["\ud83c\ude36","\u6709"],["\ud83c\ude37","\u6708"],["\ud83c\ude38","\u7533"],["\ud83c\ude39","\u5272"],["\ud83c\ude3a","\u55b6"],["\ud83c\ude3b","\u914d"],["\ud83c\ude40","\u3014\u672c\u3015"],["\ud83c\ude41","\u3014\u4e09\u3015"],["\ud83c\ude42","\u3014\u4e8c\u3015"],["\ud83c\ude43","\u3014\u5b89\u3015"],["\ud83c\ude44","\u3014\u70b9\u3015"],["\ud83c\ude45","\u3014\u6253\u3015"],["\ud83c\ude46","\u3014\u76d7\u3015"],["\ud83c\ude47","\u3014\u52dd\u3015"],["\ud83c\ude48","\u3014\u6557\u3015"],["\ud83c\ude50","\u5f97"],["\ud83c\ude51","\u53ef"],["\ud83e\udff0","0"],["\ud83e\udff1","1"],["\ud83e\udff2","2"],["\ud83e\udff3","3"],["\ud83e\udff4","4"],["\ud83e\udff5","5"],["\ud83e\udff6","6"],["\ud83e\udff7","7"],["\ud83e\udff8","8"],["\ud83e\udff9","9"],["\ud87e\udc00","\u4e3d"],["\ud87e\udc01","\u4e38"],["\ud87e\udc02","\u4e41"],["\ud87e\udc03","\ud840\udd22"],["\ud87e\udc04","\u4f60"],["\ud87e\udc05","\u4fae"],["\ud87e\udc06","\u4fbb"],["\ud87e\udc07","\u5002"],["\ud87e\udc08","\u507a"],["\ud87e\udc09","\u5099"],["\ud87e\udc0a","\u50e7"],["\ud87e\udc0b","\u50cf"],["\ud87e\udc0c","\u349e"],["\ud87e\udc0d","\ud841\ude3a"],["\ud87e\udc0e","\u514d"],["\ud87e\udc0f","\u5154"],["\ud87e\udc10","\u5164"],["\ud87e\udc11","\u5177"],["\ud87e\udc12","\ud841\udd1c"],["\ud87e\udc13","\u34b9"],["\ud87e\udc14","\u5167"],["\ud87e\udc15","\u518d"],["\ud87e\udc16","\ud841\udd4b"],["\ud87e\udc17","\u5197"],["\ud87e\udc18","\u51a4"],["\ud87e\udc19","\u4ecc"],["\ud87e\udc1a","\u51ac"],["\ud87e\udc1b","\u51b5"],["\ud87e\udc1c","\ud864\udddf"],["\ud87e\udc1d","\u51f5"],["\ud87e\udc1e","\u5203"],["\ud87e\udc1f","\u34df"],["\ud87e\udc20","\u523b"],["\ud87e\udc21","\u5246"],["\ud87e\udc22","\u5272"],["\ud87e\udc23","\u5277"],["\ud87e\udc24","\u3515"],["\ud87e\udc25","\u52c7"],["\ud87e\udc26","\u52c9"],["\ud87e\udc27","\u52e4"],["\ud87e\udc28","\u52fa"],["\ud87e\udc29","\u5305"],["\ud87e\udc2a","\u5306"],["\ud87e\udc2b","\u5317"],["\ud87e\udc2c","\u5349"],["\ud87e\udc2d","\u5351"],["\ud87e\udc2e","\u535a"],["\ud87e\udc2f","\u5373"],["\ud87e\udc30","\u537d"],["\ud87e\udc31","\u537f"],["\ud87e\udc32","\u537f"],["\ud87e\udc33","\u537f"],["\ud87e\udc34","\ud842\ude2c"],["\ud87e\udc35","\u7070"],["\ud87e\udc36","\u53ca"],["\ud87e\udc37","\u53df"],["\ud87e\udc38","\ud842\udf63"],["\ud87e\udc39","\u53eb"],["\ud87e\udc3a","\u53f1"],["\ud87e\udc3b","\u5406"],["\ud87e\udc3c","\u549e"],["\ud87e\udc3d","\u5438"],["\ud87e\udc3e","\u5448"],["\ud87e\udc3f","\u5468"],["\ud87e\udc40","\u54a2"],["\ud87e\udc41","\u54f6"],["\ud87e\udc42","\u5510"],["\ud87e\udc43","\u5553"],["\ud87e\udc44","\u5563"],["\ud87e\udc45","\u5584"],["\ud87e\udc46","\u5584"],["\ud87e\udc47","\u5599"],["\ud87e\udc48","\u55ab"],["\ud87e\udc49","\u55b3"],["\ud87e\udc4a","\u55c2"],["\ud87e\udc4b","\u5716"],["\ud87e\udc4c","\u5606"],["\ud87e\udc4d","\u5717"],["\ud87e\udc4e","\u5651"],["\ud87e\udc4f","\u5674"],["\ud87e\udc50","\u5207"],["\ud87e\udc51","\u58ee"],["\ud87e\udc52","\u57ce"],["\ud87e\udc53","\u57f4"],["\ud87e\udc54","\u580d"],["\ud87e\udc55","\u578b"],["\ud87e\udc56","\u5832"],["\ud87e\udc57","\u5831"],["\ud87e\udc58","\u58ac"],["\ud87e\udc59","\ud845\udce4"],["\ud87e\udc5a","\u58f2"],["\ud87e\udc5b","\u58f7"],["\ud87e\udc5c","\u5906"],["\ud87e\udc5d","\u591a"],["\ud87e\udc5e","\u5922"],["\ud87e\udc5f","\u5962"],["\ud87e\udc60","\ud845\udea8"],["\ud87e\udc61","\ud845\udeea"],["\ud87e\udc62","\u59ec"],["\ud87e\udc63","\u5a1b"],["\ud87e\udc64","\u5a27"],["\ud87e\udc65","\u59d8"],["\ud87e\udc66","\u5a66"],["\ud87e\udc67","\u36ee"],["\ud87e\udc68","\u36fc"],["\ud87e\udc69","\u5b08"],["\ud87e\udc6a","\u5b3e"],["\ud87e\udc6b","\u5b3e"],["\ud87e\udc6c","\ud846\uddc8"],["\ud87e\udc6d","\u5bc3"],["\ud87e\udc6e","\u5bd8"],["\ud87e\udc6f","\u5be7"],["\ud87e\udc70","\u5bf3"],["\ud87e\udc71","\ud846\udf18"],["\ud87e\udc72","\u5bff"],["\ud87e\udc73","\u5c06"],["\ud87e\udc74","\u5f53"],["\ud87e\udc75","\u5c22"],["\ud87e\udc76","\u3781"],["\ud87e\udc77","\u5c60"],["\ud87e\udc78","\u5c6e"],["\ud87e\udc79","\u5cc0"],["\ud87e\udc7a","\u5c8d"],["\ud87e\udc7b","\ud847\udde4"],["\ud87e\udc7c","\u5d43"],["\ud87e\udc7d","\ud847\udde6"],["\ud87e\udc7e","\u5d6e"],["\ud87e\udc7f","\u5d6b"],["\ud87e\udc80","\u5d7c"],["\ud87e\udc81","\u5de1"],["\ud87e\udc82","\u5de2"],["\ud87e\udc83","\u382f"],["\ud87e\udc84","\u5dfd"],["\ud87e\udc85","\u5e28"],["\ud87e\udc86","\u5e3d"],["\ud87e\udc87","\u5e69"],["\ud87e\udc88","\u3862"],["\ud87e\udc89","\ud848\udd83"],["\ud87e\udc8a","\u387c"],["\ud87e\udc8b","\u5eb0"],["\ud87e\udc8c","\u5eb3"],["\ud87e\udc8d","\u5eb6"],["\ud87e\udc8e","\u5eca"],["\ud87e\udc8f","\ud868\udf92"],["\ud87e\udc90","\u5efe"],["\ud87e\udc91","\ud848\udf31"],["\ud87e\udc92","\ud848\udf31"],["\ud87e\udc93","\u8201"],["\ud87e\udc94","\u5f22"],["\ud87e\udc95","\u5f22"],["\ud87e\udc96","\u38c7"],["\ud87e\udc97","\ud84c\udeb8"],["\ud87e\udc98","\ud858\uddda"],["\ud87e\udc99","\u5f62"],["\ud87e\udc9a","\u5f6b"],["\ud87e\udc9b","\u38e3"],["\ud87e\udc9c","\u5f9a"],["\ud87e\udc9d","\u5fcd"],["\ud87e\udc9e","\u5fd7"],["\ud87e\udc9f","\u5ff9"],["\ud87e\udca0","\u6081"],["\ud87e\udca1","\u393a"],["\ud87e\udca2","\u391c"],["\ud87e\udca3","\u6094"],["\ud87e\udca4","\ud849\uded4"],["\ud87e\udca5","\u60c7"],["\ud87e\udca6","\u6148"],["\ud87e\udca7","\u614c"],["\ud87e\udca8","\u614e"],["\ud87e\udca9","\u614c"],["\ud87e\udcaa","\u617a"],["\ud87e\udcab","\u618e"],["\ud87e\udcac","\u61b2"],["\ud87e\udcad","\u61a4"],["\ud87e\udcae","\u61af"],["\ud87e\udcaf","\u61de"],["\ud87e\udcb0","\u61f2"],["\ud87e\udcb1","\u61f6"],["\ud87e\udcb2","\u6210"],["\ud87e\udcb3","\u621b"],["\ud87e\udcb4","\u625d"],["\ud87e\udcb5","\u62b1"],["\ud87e\udcb6","\u62d4"],["\ud87e\udcb7","\u6350"],["\ud87e\udcb8","\ud84a\udf0c"],["\ud87e\udcb9","\u633d"],["\ud87e\udcba","\u62fc"],["\ud87e\udcbb","\u6368"],["\ud87e\udcbc","\u6383"],["\ud87e\udcbd","\u63e4"],["\ud87e\udcbe","\ud84a\udff1"],["\ud87e\udcbf","\u6422"],["\ud87e\udcc0","\u63c5"],["\ud87e\udcc1","\u63a9"],["\ud87e\udcc2","\u3a2e"],["\ud87e\udcc3","\u6469"],["\ud87e\udcc4","\u647e"],["\ud87e\udcc5","\u649d"],["\ud87e\udcc6","\u6477"],["\ud87e\udcc7","\u3a6c"],["\ud87e\udcc8","\u654f"],["\ud87e\udcc9","\u656c"],["\ud87e\udcca","\ud84c\udc0a"],["\ud87e\udccb","\u65e3"],["\ud87e\udccc","\u66f8"],["\ud87e\udccd","\u6649"],["\ud87e\udcce","\u3b19"],["\ud87e\udccf","\u6691"],["\ud87e\udcd0","\u3b08"],["\ud87e\udcd1","\u3ae4"],["\ud87e\udcd2","\u5192"],["\ud87e\udcd3","\u5195"],["\ud87e\udcd4","\u6700"],["\ud87e\udcd5","\u669c"],["\ud87e\udcd6","\u80ad"],["\ud87e\udcd7","\u43d9"],["\ud87e\udcd8","\u6717"],["\ud87e\udcd9","\u671b"],["\ud87e\udcda","\u6721"],["\ud87e\udcdb","\u675e"],["\ud87e\udcdc","\u6753"],["\ud87e\udcdd","\ud84c\udfc3"],["\ud87e\udcde","\u3b49"],["\ud87e\udcdf","\u67fa"],["\ud87e\udce0","\u6785"],["\ud87e\udce1","\u6852"],["\ud87e\udce2","\u6885"],["\ud87e\udce3","\ud84d\udc6d"],["\ud87e\udce4","\u688e"],["\ud87e\udce5","\u681f"],["\ud87e\udce6","\u6914"],["\ud87e\udce7","\u3b9d"],["\ud87e\udce8","\u6942"],["\ud87e\udce9","\u69a3"],["\ud87e\udcea","\u69ea"],["\ud87e\udceb","\u6aa8"],["\ud87e\udcec","\ud84d\udea3"],["\ud87e\udced","\u6adb"],["\ud87e\udcee","\u3c18"],["\ud87e\udcef","\u6b21"],["\ud87e\udcf0","\ud84e\udca7"],["\ud87e\udcf1","\u6b54"],["\ud87e\udcf2","\u3c4e"],["\ud87e\udcf3","\u6b72"],["\ud87e\udcf4","\u6b9f"],["\ud87e\udcf5","\u6bba"],["\ud87e\udcf6","\u6bbb"],["\ud87e\udcf7","\ud84e\ude8d"],["\ud87e\udcf8","\ud847\udd0b"],["\ud87e\udcf9","\ud84e\udefa"],["\ud87e\udcfa","\u6c4e"],["\ud87e\udcfb","\ud84f\udcbc"],["\ud87e\udcfc","\u6cbf"],["\ud87e\udcfd","\u6ccd"],["\ud87e\udcfe","\u6c67"],["\ud87e\udcff","\u6d16"],["\ud87e\udd00","\u6d3e"],["\ud87e\udd01","\u6d77"],["\ud87e\udd02","\u6d41"],["\ud87e\udd03","\u6d69"],["\ud87e\udd04","\u6d78"],["\ud87e\udd05","\u6d85"],["\ud87e\udd06","\ud84f\udd1e"],["\ud87e\udd07","\u6d34"],["\ud87e\udd08","\u6e2f"],["\ud87e\udd09","\u6e6e"],["\ud87e\udd0a","\u3d33"],["\ud87e\udd0b","\u6ecb"],["\ud87e\udd0c","\u6ec7"],["\ud87e\udd0d","\ud84f\uded1"],["\ud87e\udd0e","\u6df9"],["\ud87e\udd0f","\u6f6e"],["\ud87e\udd10","\ud84f\udf5e"],["\ud87e\udd11","\ud84f\udf8e"],["\ud87e\udd12","\u6fc6"],["\ud87e\udd13","\u7039"],["\ud87e\udd14","\u701e"],["\ud87e\udd15","\u701b"],["\ud87e\udd16","\u3d96"],["\ud87e\udd17","\u704a"],["\ud87e\udd18","\u707d"],["\ud87e\udd19","\u7077"],["\ud87e\udd1a","\u70ad"],["\ud87e\udd1b","\ud841\udd25"],["\ud87e\udd1c","\u7145"],["\ud87e\udd1d","\ud850\ude63"],["\ud87e\udd1e","\u719c"],["\ud87e\udd1f","\ud850\udfab"],["\ud87e\udd20","\u7228"],["\ud87e\udd21","\u7235"],["\ud87e\udd22","\u7250"],["\ud87e\udd23","\ud851\ude08"],["\ud87e\udd24","\u7280"],["\ud87e\udd25","\u7295"],["\ud87e\udd26","\ud851\udf35"],["\ud87e\udd27","\ud852\udc14"],["\ud87e\udd28","\u737a"],["\ud87e\udd29","\u738b"],["\ud87e\udd2a","\u3eac"],["\ud87e\udd2b","\u73a5"],["\ud87e\udd2c","\u3eb8"],["\ud87e\udd2d","\u3eb8"],["\ud87e\udd2e","\u7447"],["\ud87e\udd2f","\u745c"],["\ud87e\udd30","\u7471"],["\ud87e\udd31","\u7485"],["\ud87e\udd32","\u74ca"],["\ud87e\udd33","\u3f1b"],["\ud87e\udd34","\u7524"],["\ud87e\udd35","\ud853\udc36"],["\ud87e\udd36","\u753e"],["\ud87e\udd37","\ud853\udc92"],["\ud87e\udd38","\u7570"],["\ud87e\udd39","\ud848\udd9f"],["\ud87e\udd3a","\u7610"],["\ud87e\udd3b","\ud853\udfa1"],["\ud87e\udd3c","\ud853\udfb8"],["\ud87e\udd3d","\ud854\udc44"],["\ud87e\udd3e","\u3ffc"],["\ud87e\udd3f","\u4008"],["\ud87e\udd40","\u76f4"],["\ud87e\udd41","\ud854\udcf3"],["\ud87e\udd42","\ud854\udcf2"],["\ud87e\udd43","\ud854\udd19"],["\ud87e\udd44","\ud854\udd33"],["\ud87e\udd45","\u771e"],["\ud87e\udd46","\u771f"],["\ud87e\udd47","\u771f"],["\ud87e\udd48","\u774a"],["\ud87e\udd49","\u4039"],["\ud87e\udd4a","\u778b"],["\ud87e\udd4b","\u4046"],["\ud87e\udd4c","\u4096"],["\ud87e\udd4d","\ud855\udc1d"],["\ud87e\udd4e","\u784e"],["\ud87e\udd4f","\u788c"],["\ud87e\udd50","\u78cc"],["\ud87e\udd51","\u40e3"],["\ud87e\udd52","\ud855\ude26"],["\ud87e\udd53","\u7956"],["\ud87e\udd54","\ud855\ude9a"],["\ud87e\udd55","\ud855\udec5"],["\ud87e\udd56","\u798f"],["\ud87e\udd57","\u79eb"],["\ud87e\udd58","\u412f"],["\ud87e\udd59","\u7a40"],["\ud87e\udd5a","\u7a4a"],["\ud87e\udd5b","\u7a4f"],["\ud87e\udd5c","\ud856\udd7c"],["\ud87e\udd5d","\ud856\udea7"],["\ud87e\udd5e","\ud856\udea7"],["\ud87e\udd5f","\u7aee"],["\ud87e\udd60","\u4202"],["\ud87e\udd61","\ud856\udfab"],["\ud87e\udd62","\u7bc6"],["\ud87e\udd63","\u7bc9"],["\ud87e\udd64","\u4227"],["\ud87e\udd65","\ud857\udc80"],["\ud87e\udd66","\u7cd2"],["\ud87e\udd67","\u42a0"],["\ud87e\udd68","\u7ce8"],["\ud87e\udd69","\u7ce3"],["\ud87e\udd6a","\u7d00"],["\ud87e\udd6b","\ud857\udf86"],["\ud87e\udd6c","\u7d63"],["\ud87e\udd6d","\u4301"],["\ud87e\udd6e","\u7dc7"],["\ud87e\udd6f","\u7e02"],["\ud87e\udd70","\u7e45"],["\ud87e\udd71","\u4334"],["\ud87e\udd72","\ud858\ude28"],["\ud87e\udd73","\ud858\ude47"],["\ud87e\udd74","\u4359"],["\ud87e\udd75","\ud858\uded9"],["\ud87e\udd76","\u7f7a"],["\ud87e\udd77","\ud858\udf3e"],["\ud87e\udd78","\u7f95"],["\ud87e\udd79","\u7ffa"],["\ud87e\udd7a","\u8005"],["\ud87e\udd7b","\ud859\udcda"],["\ud87e\udd7c","\ud859\udd23"],["\ud87e\udd7d","\u8060"],["\ud87e\udd7e","\ud859\udda8"],["\ud87e\udd7f","\u8070"],["\ud87e\udd80","\ud84c\udf5f"],["\ud87e\udd81","\u43d5"],["\ud87e\udd82","\u80b2"],["\ud87e\udd83","\u8103"],["\ud87e\udd84","\u440b"],["\ud87e\udd85","\u813e"],["\ud87e\udd86","\u5ab5"],["\ud87e\udd87","\ud859\udfa7"],["\ud87e\udd88","\ud859\udfb5"],["\ud87e\udd89","\ud84c\udf93"],["\ud87e\udd8a","\ud84c\udf9c"],["\ud87e\udd8b","\u8201"],["\ud87e\udd8c","\u8204"],["\ud87e\udd8d","\u8f9e"],["\ud87e\udd8e","\u446b"],["\ud87e\udd8f","\u8291"],["\ud87e\udd90","\u828b"],["\ud87e\udd91","\u829d"],["\ud87e\udd92","\u52b3"],["\ud87e\udd93","\u82b1"],["\ud87e\udd94","\u82b3"],["\ud87e\udd95","\u82bd"],["\ud87e\udd96","\u82e6"],["\ud87e\udd97","\ud85a\udf3c"],["\ud87e\udd98","\u82e5"],["\ud87e\udd99","\u831d"],["\ud87e\udd9a","\u8363"],["\ud87e\udd9b","\u83ad"],["\ud87e\udd9c","\u8323"],["\ud87e\udd9d","\u83bd"],["\ud87e\udd9e","\u83e7"],["\ud87e\udd9f","\u8457"],["\ud87e\udda0","\u8353"],["\ud87e\udda1","\u83ca"],["\ud87e\udda2","\u83cc"],["\ud87e\udda3","\u83dc"],["\ud87e\udda4","\ud85b\udc36"],["\ud87e\udda5","\ud85b\udd6b"],["\ud87e\udda6","\ud85b\udcd5"],["\ud87e\udda7","\u452b"],["\ud87e\udda8","\u84f1"],["\ud87e\udda9","\u84f3"],["\ud87e\uddaa","\u8516"],["\ud87e\uddab","\ud85c\udfca"],["\ud87e\uddac","\u8564"],["\ud87e\uddad","\ud85b\udf2c"],["\ud87e\uddae","\u455d"],["\ud87e\uddaf","\u4561"],["\ud87e\uddb0","\ud85b\udfb1"],["\ud87e\uddb1","\ud85c\udcd2"],["\ud87e\uddb2","\u456b"],["\ud87e\uddb3","\u8650"],["\ud87e\uddb4","\u865c"],["\ud87e\uddb5","\u8667"],["\ud87e\uddb6","\u8669"],["\ud87e\uddb7","\u86a9"],["\ud87e\uddb8","\u8688"],["\ud87e\uddb9","\u870e"],["\ud87e\uddba","\u86e2"],["\ud87e\uddbb","\u8779"],["\ud87e\uddbc","\u8728"],["\ud87e\uddbd","\u876b"],["\ud87e\uddbe","\u8786"],["\ud87e\uddbf","\u45d7"],["\ud87e\uddc0","\u87e1"],["\ud87e\uddc1","\u8801"],["\ud87e\uddc2","\u45f9"],["\ud87e\uddc3","\u8860"],["\ud87e\uddc4","\u8863"],["\ud87e\uddc5","\ud85d\ude67"],["\ud87e\uddc6","\u88d7"],["\ud87e\uddc7","\u88de"],["\ud87e\uddc8","\u4635"],["\ud87e\uddc9","\u88fa"],["\ud87e\uddca","\u34bb"],["\ud87e\uddcb","\ud85e\udcae"],["\ud87e\uddcc","\ud85e\udd66"],["\ud87e\uddcd","\u46be"],["\ud87e\uddce","\u46c7"],["\ud87e\uddcf","\u8aa0"],["\ud87e\uddd0","\u8aed"],["\ud87e\uddd1","\u8b8a"],["\ud87e\uddd2","\u8c55"],["\ud87e\uddd3","\ud85f\udca8"],["\ud87e\uddd4","\u8cab"],["\ud87e\uddd5","\u8cc1"],["\ud87e\uddd6","\u8d1b"],["\ud87e\uddd7","\u8d77"],["\ud87e\uddd8","\ud85f\udf2f"],["\ud87e\uddd9","\ud842\udc04"],["\ud87e\uddda","\u8dcb"],["\ud87e\udddb","\u8dbc"],["\ud87e\udddc","\u8df0"],["\ud87e\udddd","\ud842\udcde"],["\ud87e\uddde","\u8ed4"],["\ud87e\udddf","\u8f38"],["\ud87e\udde0","\ud861\uddd2"],["\ud87e\udde1","\ud861\udded"],["\ud87e\udde2","\u9094"],["\ud87e\udde3","\u90f1"],["\ud87e\udde4","\u9111"],["\ud87e\udde5","\ud861\udf2e"],["\ud87e\udde6","\u911b"],["\ud87e\udde7","\u9238"],["\ud87e\udde8","\u92d7"],["\ud87e\udde9","\u92d8"],["\ud87e\uddea","\u927c"],["\ud87e\uddeb","\u93f9"],["\ud87e\uddec","\u9415"],["\ud87e\udded","\ud862\udffa"],["\ud87e\uddee","\u958b"],["\ud87e\uddef","\u4995"],["\ud87e\uddf0","\u95b7"],["\ud87e\uddf1","\ud863\udd77"],["\ud87e\uddf2","\u49e6"],["\ud87e\uddf3","\u96c3"],["\ud87e\uddf4","\u5db2"],["\ud87e\uddf5","\u9723"],["\ud87e\uddf6","\ud864\udd45"],["\ud87e\uddf7","\ud864\ude1a"],["\ud87e\uddf8","\u4a6e"],["\ud87e\uddf9","\u4a76"],["\ud87e\uddfa","\u97e0"],["\ud87e\uddfb","\ud865\udc0a"],["\ud87e\uddfc","\u4ab2"],["\ud87e\uddfd","\ud865\udc96"],["\ud87e\uddfe","\u980b"],["\ud87e\uddff","\u980b"],["\ud87e\ude00","\u9829"],["\ud87e\ude01","\ud865\uddb6"],["\ud87e\ude02","\u98e2"],["\ud87e\ude03","\u4b33"],["\ud87e\ude04","\u9929"],["\ud87e\ude05","\u99a7"],["\ud87e\ude06","\u99c2"],["\ud87e\ude07","\u99fe"],["\ud87e\ude08","\u4bce"],["\ud87e\ude09","\ud866\udf30"],["\ud87e\ude0a","\u9b12"],["\ud87e\ude0b","\u9c40"],["\ud87e\ude0c","\u9cfd"],["\ud87e\ude0d","\u4cce"],["\ud87e\ude0e","\u4ced"],["\ud87e\ude0f","\u9d67"],["\ud87e\ude10","\ud868\udcce"],["\ud87e\ude11","\u4cf8"],["\ud87e\ude12","\ud868\udd05"],["\ud87e\ude13","\ud868\ude0e"],["\ud87e\ude14","\ud868\ude91"],["\ud87e\ude15","\u9ebb"],["\ud87e\ude16","\u4d56"],["\ud87e\ude17","\u9ef9"],["\ud87e\ude18","\u9efe"],["\ud87e\ude19","\u9f05"],["\ud87e\ude1a","\u9f0f"],["\ud87e\ude1b","\u9f16"],["\ud87e\ude1c","\u9f3b"],["\ud87e\ude1d","\ud869\ude00"]],"whitespace":["\t","\n","\u000b","\f","\r","\u001c","\u001d","\u001e","\u001f"," ","\u0085","\u00a0","\u1680","\u2000","\u2001","\u2002","\u2003","\u2004","\u2005","\u2006","\u2007","\u2008","\u2009","\u200a","\u2028","\u2029","\u202f","\u205f","\u3000"],"word_ranges":[[48,57],[65,90],[97,122],[170,170],[178,179],[181,181],[185,186],[188,190],[192,214],[216,246],[248,705],[710,721],[736,740],[748,748],[750,750],[880,884],[886,887],[890,893],[895,895],[902,902],[904,906],[908,908],[910,929],[931,1013],[1015,1153],[1162,1327],[1329,1366],[1369,1369],[1376,1416],[1488,1514],[1519,1522],[1568,1610],[1632,1641],[1646,1647],[1649,1747],[1749,1749],[1765,1766],[1774,1788],[1791,1791],[1808,1808],[1810,1839],[1869,1957],[1969,1969],[1984,2026],[2036,2037],[2042,2042],[2048,2069],[2074,2074],[2084,2084],[2088,2088],[2112,2136],[2144,2154],[2160,2183],[2185,2190],[2208,2249],[2308,2361],[2365,2365],[2384,2384],[2392,2401],[2406,2415],[2417,2432],[2437,2444],[2447,2448],[2451,2472],[2474,2480],[2482,2482],[2486,2489],[2493,2493],[2510,2510],[2524,2525],[2527,2529],[2534,2545],[2548,2553],[2556,2556],[2565,2570],[2575,2576],[2579,2600],[2602,2608],[2610,2611],[2613,2614],[2616,2617],[2649,2652],[2654,2654],[2662,2671],[2674,2676],[2693,2701],[2703,2705],[2707,2728],[2730,2736],[2738,2739],[2741,2745],[2749,2749],[2768,2768],[2784,2785],[2790,2799],[2809,2809],[2821,2828],[2831,2832],[2835,2856],[2858,2864],[2866,2867],[2869,2873],[2877,2877],[2908,2909],[2911,2913],[2918,2927],[2929,2935],[2947,2947],[2949,2954],[2958,2960],[2962,2965],[2969,2970],[2972,2972],[2974,2975],[2979,2980],[2984,2986],[2990,3001],[3024,3024],[3046,3058],[3077,3084],[3086,3088],[3090,3112],[3114,3129],[3133,3133],[3160,3162],[3165,3165],[3168,3169],[3174,3183],[3192,3198],[3200,3200],[3205,3212],[3214,3216],[3218,3240],[3242,3251],[3253,3257],[3261,3261],[3293,3294],[3296,3297],[3302,3311],[3313,3314],[3332,3340],[3342,3344],[3346,3386],[3389,3389],[3406,3406],[3412,3414],[3416,3425],[3430,3448],[3450,3455],[3461,3478],[3482,3505],[3507,3515],[3517,3517],[3520,3526],[3558,3567],[3585,3632],[3634,3635],[3648,3654],[3664,3673],[3713,3714],[3716,3716],[3718,3722],[3724,3747],[3749,3749],[3751,3760],[3762,3763],[3773,3773],[3776,3780],[3782,3782],[3792,3801],[3804,3807],[3840,3840],[3872,3891],[3904,3911],[3913,3948],[3976,3980],[4096,4138],[4159,4169],[4176,4181],[4186,4189],[4193,4193],[4197,4198],[4206,4208],[4213,4225],[4238,4238],[4240,4249],[4256,4293],[4295,4295],[4301,4301],[4304,4346],[4348,4680],[4682,4685],[4688,4694],[4696,4696],[4698,4701],[4704,4744],[4746,4749],[4752,4784],[4786,4789],[4792,4798],[4800,4800],[4802,4805],[4808,4822],[4824,4880],[4882,4885],[4888,4954],[4969,4988],[4992,5007],[5024,5109],[5112,5117],[5121,5740],[5743,5759],[5761,5786],[5792,5866],[5870,5880],[5888,5905],[5919,5937],[5952,5969],[5984,5996],[5998,6000],[6016,6067],[6103,6103],[6108,6108],[6112,6121],[6128,6137],[6160,6169],[6176,6264],[6272,6276],[6279,6312],[6314,6314],[6320,6389],[6400,6430],[6470,6509],[6512,6516],[6528,6571],[6576,6601],[6608,6618],[6656,6678],[6688,6740],[6784,6793],[6800,6809],[6823,6823],[6917,6963],[6981,6988],[6992,7001],[7043,7072],[7086,7141],[7168,7203],[7232,7241],[7245,7293],[7296,7306],[7312,7354],[7357,7359],[7401,7404],[7406,7411],[7413,7414],[7418,7418],[7424,7615],[7680,7957],[7960,7965],[7968,8005],[8008,8013],[8016,8023],[8025,8025],[8027,8027],[8029,8029],[8031,8061],[8064,8116],[8118,8124],[8126,8126],[8130,8132],[8134,8140],[8144,8147],[8150,8155],[8160,8172],[8178,8180],[8182,8188],[8304,8305],[8308,8313],[8319,8329],[8336,8348],[8450,8450],[8455,8455],[8458,8467],[8469,8469],[8473,8477],[8484,8484],[8486,8486],[8488,8488],[8490,8493],[8495,8505],[8508,8511],[8517,8521],[8526,8526],[8528,8585],[9312,9371],[9450,9471],[10102,10131],[11264,11492],[11499,11502],[11506,11507],[11517,11517],[11520,11557],[11559,11559],[11565,11565],[11568,11623],[11631,11631],[11648,11670],[11680,11686],[11688,11694],[11696,11702],[11704,11710],[11712,11718],[11720,11726],[11728,11734],[11736,11742],[11823,11823],[12293,12295],[12321,12329],[12337,12341],[12344,12348],[12353,12438],[12445,12447],[12449,12538],[12540,12543],[12549,12591],[12593,12686],[12690,12693],[12704,12735],[12784,12799],[12832,12841],[12872,12879],[12881,12895],[12928,12937],[12977,12991],[13312,19903],[19968,42124],[42192,42237],[42240,42508],[42512,42539],[42560,42606],[42623,42653],[42656,42735],[42775,42783],[42786,42888],[42891,42957],[42960,42961],[42963,42963],[42965,42972],[42994,43009],[43011,43013],[43015,43018],[43020,43042],[43056,43061],[43072,43123],[43138,43187],[43216,43225],[43250,43255],[43259,43259],[43261,43262],[43264,43301],[43312,43334],[43360,43388],[43396,43442],[43471,43481],[43488,43492],[43494,43518],[43520,43560],[43584,43586],[43588,43595],[43600,43609],[43616,43638],[43642,43642],[43646,43695],[43697,43697],[43701,43702],[43705,43709],[43712,43712],[43714,43714],[43739,43741],[43744,43754],[43762,43764],[43777,43782],[43785,43790],[43793,43798],[43808,43814],[43816,43822],[43824,43866],[43868,43881],[43888,44002],[44016,44025],[44032,55203],[55216,55238],[55243,55291],[63744,64109],[64112,64217],[64256,64262],[64275,64279],[64285,64285],[64287,64296],[64298,64310],[64312,64316],[64318,64318],[64320,64321],[64323,64324],[64326,64433],[64467,64829],[64848,64911],[64914,64967],[65008,65019],[65136,65140],[65142,65276],[65296,65305],[65313,65338],[65345,65370],[65382,65470],[65474,65479],[65482,65487],[65490,65495],[65498,65500],[65536,65547],[65549,65574],[65576,65594],[65596,65597],[65599,65613],[65616,65629],[65664,65786],[65799,65843],[65856,65912],[65930,65931],[66176,66204],[66208,66256],[66273,66299],[66304,66339],[66349,66378],[66384,66421],[66432,66461],[66464,66499],[66504,66511],[66513,66517],[66560,66717],[66720,66729],[66736,66771],[66776,66811],[66816,66855],[66864,66915],[66928,66938],[66940,66954],[66956,66962],[66964,66965],[66967,66977],[66979,66993],[66995,67001],[67003,67004],[67008,67059],[67072,67382],[67392,67413],[67424,67431],[67456,67461],[67463,67504],[67506,67514],[67584,67589],[67592,67592],[67594,67637],[67639,67640],[67644,67644],[67647,67669],[67672,67702],[67705,67742],[67751,67759],[67808,67826],[67828,67829],[67835,67867],[67872,67897],[67968,68023],[68028,68047],[68050,68096],[68112,68115],[68117,68119],[68121,68149],[68160,68168],[68192,68222],[68224,68255],[68288,68295],[68297,68324],[68331,68335],[68352,68405],[68416,68437],[68440,68466],[68472,68497],[68521,68527],[68608,68680],[68736,68786],[68800,68850],[68858,68899],[68912,68921],[68928,68965],[68975,68997],[69216,69246],[69248,69289],[69296,69297],[69314,69316],[69376,69415],[69424,69445],[69457,69460],[69488,69505],[69552,69579],[69600,69622],[69635,69687],[69714,69743],[69745,69746],[69749,69749],[69763,69807],[69840,69864],[69872,69881],[69891,69926],[69942,69951],[69956,69956],[69959,69959],[69968,70002],[70006,70006],[70019,70066],[70081,70084],[70096,70106],[70108,70108],[70113,70132],[70144,70161],[70163,70187],[70207,70208],[70272,70278],[70280,70280],[70282,70285],[70287,70301],[70303,70312],[70320,70366],[70384,70393],[70405,70412],[70415,70416],[70419,70440],[70442,70448],[70450,70451],[70453,70457],[70461,70461],[70480,70480],[70493,70497],[70528,70537],[70539,70539],[70542,70542],[70544,70581],[70583,70583],[70609,70609],[70611,70611],[70656,70708],[70727,70730],[70736,70745],[70751,70753],[70784,70831],[70852,70853],[70855,70855],[70864,70873],[71040,71086],[71128,71131],[71168,71215],[71236,71236],[71248,71257],[71296,71338],[71352,71352],[71360,71369],[71376,71395],[71424,71450],[71472,71483],[71488,71494],[71680,71723],[71840,71922],[71935,71942],[71945,71945],[71948,71955],[71957,71958],[71960,71983],[71999,71999],[72001,72001],[72016,72025],[72096,72103],[72106,72144],[72161,72161],[72163,72163],[72192,72192],[72203,72242],[72250,72250],[72272,72272],[72284,72329],[72349,72349],[72368,72440],[72640,72672],[72688,72697],[72704,72712],[72714,72750],[72768,72768],[72784,72812],[72818,72847],[72960,72966],[72968,72969],[72971,73008],[73030,73030],[73040,73049],[73056,73061],[73063,73064],[73066,73097],[73112,73112],[73120,73129],[73440,73458],[73474,73474],[73476,73488],[73490,73523],[73552,73561],[73648,73648],[73664,73684],[73728,74649],[74752,74862],[74880,75075],[77712,77808],[77824,78895],[78913,78918],[78944,82938],[82944,83526],[90368,90397],[90416,90425],[92160,92728],[92736,92766],[92768,92777],[92784,92862],[92864,92873],[92880,92909],[92928,92975],[92992,92995],[93008,93017],[93019,93025],[93027,93047],[93053,93071],[93504,93548],[93552,93561],[93760,93846],[93952,94026],[94032,94032],[94099,94111],[94176,94177],[94179,94179],[94208,100343],[100352,101589],[101631,101640],[110576,110579],[110581,110587],[110589,110590],[110592,110882],[110898,110898],[110928,110930],[110933,110933],[110948,110951],[110960,111355],[113664,113770],[113776,113788],[113792,113800],[113808,113817],[118000,118009],[119488,119507],[119520,119539],[119648,119672],[119808,119892],[119894,119964],[119966,119967],[119970,119970],[119973,119974],[119977,119980],[119982,119993],[119995,119995],[119997,120003],[120005,120069],[120071,120074],[120077,120084],[120086,120092],[120094,120121],[120123,120126],[120128,120132],[120134,120134],[120138,120144],[120146,120485],[120488,120512],[120514,120538],[120540,120570],[120572,120596],[120598,120628],[120630,120654],[120656,120686],[120688,120712],[120714,120744],[120746,120770],[120772,120779],[120782,120831],[122624,122654],[122661,122666],[122928,122989],[123136,123180],[123191,123197],[123200,123209],[123214,123214],[123536,123565],[123584,123627],[123632,123641],[124112,124139],[124144,124153],[124368,124397],[124400,124410],[124896,124902],[124904,124907],[124909,124910],[124912,124926],[124928,125124],[125127,125135],[125184,125251],[125259,125259],[125264,125273],[126065,126123],[126125,126127],[126129,126132],[126209,126253],[126255,126269],[126464,126467],[126469,126495],[126497,126498],[126500,126500],[126503,126503],[126505,126514],[126516,126519],[126521,126521],[126523,126523],[126530,126530],[126535,126535],[126537,126537],[126539,126539],[126541,126543],[126545,126546],[126548,126548],[126551,126551],[126553,126553],[126555,126555],[126557,126557],[126559,126559],[126561,126562],[126564,126564],[126567,126570],[126572,126578],[126580,126583],[126585,126588],[126590,126590],[126592,126601],[126603,126619],[126625,126627],[126629,126633],[126635,126651],[127232,127244],[130032,130041],[131072,173791],[173824,177977],[177984,178205],[178208,183969],[183984,191456],[191472,192093],[194560,195101],[196608,201546],[201552,205743]]} diff --git a/shared/directory-index/validate-generations.sql b/shared/directory-index/validate-generations.sql new file mode 100644 index 000000000..5e5531d18 --- /dev/null +++ b/shared/directory-index/validate-generations.sql @@ -0,0 +1,14 @@ +SELECT NOT EXISTS ( + SELECT 1 FROM source_metadata AS metadata + JOIN generations AS generation ON generation.id = metadata.selected_generation + WHERE metadata.id = 1 AND generation.state IN ('building', 'accepted') +) OR EXISTS ( + SELECT 1 FROM generations AS generation + WHERE generation.indexed_count <> ( + SELECT count(*) FROM directories WHERE generation_id = generation.id + ) OR generation.issue_count <> coalesce(( + SELECT sum(count) FROM coverage_issues WHERE generation_id = generation.id + ), 0) + OR (generation.coverage = 'complete' AND generation.issue_count <> 0) + OR (generation.state = 'building' AND generation.coverage = 'complete') +); diff --git a/website-meta/docs-reports/docs-structure-report.html b/website-meta/docs-reports/docs-structure-report.html index cfcd831d4..5eeec6398 100644 --- a/website-meta/docs-reports/docs-structure-report.html +++ b/website-meta/docs-reports/docs-structure-report.html @@ -268,7 +268,7 @@

Content diff

- +