diff --git a/.github/actions/build-win7/action.yml b/.github/actions/build-win7/action.yml index ef3cffdb..ec6fca66 100644 --- a/.github/actions/build-win7/action.yml +++ b/.github/actions/build-win7/action.yml @@ -87,19 +87,7 @@ runs: - name: Enable Win7-only Cargo patches shell: pwsh - run: | - $ErrorActionPreference = "Stop" - - New-Item -ItemType Directory -Force -Path ".cargo" | Out-Null - @' - [patch.crates-io] - webview2-com-sys = { path = "src-tauri/vendor/webview2-com-sys" } - windows-core = { path = "src-tauri/vendor/windows-core" } - '@ | Set-Content -LiteralPath ".cargo/config.toml" -Encoding UTF8 - - cargo metadata --manifest-path src-tauri/Cargo.toml --format-version 1 | Out-Null - Write-Host "Enabled Win7-only Cargo patches:" - Get-Content -LiteralPath ".cargo/config.toml" + run: ./.github/scripts/enable-win7-cargo-patches.ps1 - name: Print build metadata shell: pwsh diff --git a/.github/scripts/enable-win7-cargo-patches.ps1 b/.github/scripts/enable-win7-cargo-patches.ps1 new file mode 100644 index 00000000..8e17f5fb --- /dev/null +++ b/.github/scripts/enable-win7-cargo-patches.ps1 @@ -0,0 +1,89 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$knownCtorVersion = "0.8.0" +$knownCtorMacrosSha256 = "86ec55f4670e68dbd0fb6f400be0374ba14ac9b621ba041561de7dc629e22fcc" + +$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$tauriManifest = Join-Path $repositoryRoot "src-tauri\Cargo.toml" +$cargoConfigDirectory = Join-Path $repositoryRoot ".cargo" +$cargoConfigPath = Join-Path $cargoConfigDirectory "config.toml" +$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) "nyaterm-win7-cargo-patches-$([Guid]::NewGuid())" +$patchedCtorRoot = Join-Path $temporaryRoot "ctor-$knownCtorVersion" + +Push-Location $repositoryRoot +try { + $metadataJson = & cargo metadata --manifest-path $tauriManifest --locked --format-version 1 + if ($LASTEXITCODE -ne 0) { + throw "cargo metadata failed while locating ctor $knownCtorVersion." + } + + $metadata = $metadataJson | ConvertFrom-Json + $ctorPackages = @($metadata.packages | Where-Object { + $_.name -eq "ctor" -and $_.version -eq $knownCtorVersion + }) + if ($ctorPackages.Count -ne 1) { + throw "Expected exactly one ctor $knownCtorVersion package, found $($ctorPackages.Count)." + } + + $ctorRoot = Split-Path -Parent $ctorPackages[0].manifest_path + $ctorMacrosPath = Join-Path $ctorRoot "src\macros\mod.rs" + if (!(Test-Path -LiteralPath $ctorMacrosPath -PathType Leaf)) { + throw "ctor macros file does not exist: $ctorMacrosPath" + } + + $actualCtorMacrosSha256 = (Get-FileHash -LiteralPath $ctorMacrosPath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualCtorMacrosSha256 -ne $knownCtorMacrosSha256) { + throw "Unexpected ctor $knownCtorVersion macros SHA256. Expected $knownCtorMacrosSha256, got $actualCtorMacrosSha256." + } + + New-Item -ItemType Directory -Path $temporaryRoot -Force | Out-Null + Copy-Item -LiteralPath $ctorRoot -Destination $patchedCtorRoot -Recurse -Force + + $patchedMacrosPath = Join-Path $patchedCtorRoot "src\macros\mod.rs" + $patchedMacros = Get-Content -LiteralPath $patchedMacrosPath -Raw + $unsupportedVendorCondition = 'target_vendor = "pc"' + $replacementCount = ([regex]::Matches($patchedMacros, [regex]::Escape($unsupportedVendorCondition))).Count + if ($replacementCount -ne 3) { + throw "Expected three Windows vendor checks in ctor $knownCtorVersion, found $replacementCount." + } + + # The built-in Win7 targets use target_vendor="win7" but retain the normal + # Windows MSVC CRT constructor sections. This is the same compatibility fix + # released upstream in ctor 1.0.4, kept on 0.8.0 for Tauri's version range. + $patchedMacros = $patchedMacros.Replace($unsupportedVendorCondition, 'target_os = "windows"') + Set-Content -LiteralPath $patchedMacrosPath -Value $patchedMacros -Encoding UTF8 -NoNewline + + $cargoCtorPath = $patchedCtorRoot.Replace("\", "/").Replace("'", "''") + New-Item -ItemType Directory -Path $cargoConfigDirectory -Force | Out-Null + @" +[patch.crates-io] +ctor = { path = '$cargoCtorPath' } +webview2-com-sys = { path = "src-tauri/vendor/webview2-com-sys" } +windows-core = { path = "src-tauri/vendor/windows-core" } +"@ | Set-Content -LiteralPath $cargoConfigPath -Encoding UTF8 + + $patchedMetadataJson = & cargo metadata --manifest-path $tauriManifest --format-version 1 + if ($LASTEXITCODE -ne 0) { + throw "cargo metadata failed after enabling the Windows 7 Cargo patches." + } + + $patchedMetadata = $patchedMetadataJson | ConvertFrom-Json + $activeCtorPackages = @($patchedMetadata.packages | Where-Object { + $_.name -eq "ctor" -and $_.version -eq $knownCtorVersion -and + (Split-Path -Parent $_.manifest_path) -eq $patchedCtorRoot + }) + if ($activeCtorPackages.Count -ne 1) { + throw "The patched ctor $knownCtorVersion package was not selected by Cargo." + } + + Write-Host "Enabled Win7-only Cargo patches:" + Get-Content -LiteralPath $cargoConfigPath + Write-Host "Patched ctor source: $patchedCtorRoot" +} +finally { + Pop-Location +} diff --git a/package.json b/package.json index 298da2bd..03576f0e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nyaterm", "private": true, - "version": "1.2.5", + "version": "1.2.6", "description": "A modern, high-performance SSH client built with Tauri and React.", "author": "NyaKang", "homepage": "https://nyaterm.app", @@ -67,9 +67,9 @@ "@lezer/highlight": "^1.2.3", "@mdxeditor/editor": "^4.2.0", "@tanstack/react-virtual": "^3.14.6", - "@tauri-apps/api": "^2", - "@tauri-apps/plugin-dialog": "^2.6.0", - "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/api": "^2.11.1", + "@tauri-apps/plugin-dialog": "^2.7.2", + "@tauri-apps/plugin-opener": "^2.5.4", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "~2.10.1", "@types/papaparse": "^5.5.2", @@ -107,7 +107,7 @@ "devDependencies": { "@biomejs/biome": "^2.4.2", "@tailwindcss/vite": "^4.1.18", - "@tauri-apps/cli": "^2", + "@tauri-apps/cli": "^2.11.4", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^25.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a509c88..da4b7bcd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,14 +93,14 @@ importers: specifier: ^3.14.6 version: 3.14.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tauri-apps/api': - specifier: ^2 - version: 2.10.1 + specifier: ^2.11.1 + version: 2.11.1 '@tauri-apps/plugin-dialog': - specifier: ^2.6.0 - version: 2.6.0 + specifier: ^2.7.2 + version: 2.7.2 '@tauri-apps/plugin-opener': - specifier: ^2 - version: 2.5.3 + specifier: ^2.5.4 + version: 2.5.4 '@tauri-apps/plugin-process': specifier: ^2.3.1 version: 2.3.1 @@ -208,8 +208,8 @@ importers: specifier: ^4.1.18 version: 4.1.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)) '@tauri-apps/cli': - specifier: ^2 - version: 2.10.0 + specifier: ^2.11.4 + version: 2.11.4 '@testing-library/react': specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -4222,90 +4222,90 @@ packages: '@tanstack/virtual-core@3.17.4': resolution: {integrity: sha512-nGm5KteqxasUdThLc2izl6dHUqLv0LQj7Nuyo5gYalTPf/U8a9ermvsl7reT+6ioBW1l8WfpP/mcU338nLXpqw==} - '@tauri-apps/api@2.10.1': - resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} + '@tauri-apps/api@2.11.1': + resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==} - '@tauri-apps/cli-darwin-arm64@2.10.0': - resolution: {integrity: sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA==} + '@tauri-apps/cli-darwin-arm64@2.11.4': + resolution: {integrity: sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@tauri-apps/cli-darwin-x64@2.10.0': - resolution: {integrity: sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q==} + '@tauri-apps/cli-darwin-x64@2.11.4': + resolution: {integrity: sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@tauri-apps/cli-linux-arm-gnueabihf@2.10.0': - resolution: {integrity: sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g==} + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': + resolution: {integrity: sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@tauri-apps/cli-linux-arm64-gnu@2.10.0': - resolution: {integrity: sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA==} + '@tauri-apps/cli-linux-arm64-gnu@2.11.4': + resolution: {integrity: sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@tauri-apps/cli-linux-arm64-musl@2.10.0': - resolution: {integrity: sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==} + '@tauri-apps/cli-linux-arm64-musl@2.11.4': + resolution: {integrity: sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@tauri-apps/cli-linux-riscv64-gnu@2.10.0': - resolution: {integrity: sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==} + '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': + resolution: {integrity: sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] libc: [glibc] - '@tauri-apps/cli-linux-x64-gnu@2.10.0': - resolution: {integrity: sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==} + '@tauri-apps/cli-linux-x64-gnu@2.11.4': + resolution: {integrity: sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@tauri-apps/cli-linux-x64-musl@2.10.0': - resolution: {integrity: sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==} + '@tauri-apps/cli-linux-x64-musl@2.11.4': + resolution: {integrity: sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@tauri-apps/cli-win32-arm64-msvc@2.10.0': - resolution: {integrity: sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==} + '@tauri-apps/cli-win32-arm64-msvc@2.11.4': + resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@tauri-apps/cli-win32-ia32-msvc@2.10.0': - resolution: {integrity: sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A==} + '@tauri-apps/cli-win32-ia32-msvc@2.11.4': + resolution: {integrity: sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] - '@tauri-apps/cli-win32-x64-msvc@2.10.0': - resolution: {integrity: sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ==} + '@tauri-apps/cli-win32-x64-msvc@2.11.4': + resolution: {integrity: sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@tauri-apps/cli@2.10.0': - resolution: {integrity: sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q==} + '@tauri-apps/cli@2.11.4': + resolution: {integrity: sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==} engines: {node: '>= 10'} hasBin: true - '@tauri-apps/plugin-dialog@2.6.0': - resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==} + '@tauri-apps/plugin-dialog@2.7.2': + resolution: {integrity: sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==} - '@tauri-apps/plugin-opener@2.5.3': - resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==} + '@tauri-apps/plugin-opener@2.5.4': + resolution: {integrity: sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==} '@tauri-apps/plugin-process@2.3.1': resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==} @@ -11490,7 +11490,7 @@ snapshots: '@types/react-router-config': 5.0.11 combine-promises: 1.2.0 fs-extra: 11.3.3 - js-yaml: 4.1.1 + js-yaml: 4.3.0 lodash: 4.18.1 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -14480,70 +14480,70 @@ snapshots: '@tanstack/virtual-core@3.17.4': {} - '@tauri-apps/api@2.10.1': {} + '@tauri-apps/api@2.11.1': {} - '@tauri-apps/cli-darwin-arm64@2.10.0': + '@tauri-apps/cli-darwin-arm64@2.11.4': optional: true - '@tauri-apps/cli-darwin-x64@2.10.0': + '@tauri-apps/cli-darwin-x64@2.11.4': optional: true - '@tauri-apps/cli-linux-arm-gnueabihf@2.10.0': + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': optional: true - '@tauri-apps/cli-linux-arm64-gnu@2.10.0': + '@tauri-apps/cli-linux-arm64-gnu@2.11.4': optional: true - '@tauri-apps/cli-linux-arm64-musl@2.10.0': + '@tauri-apps/cli-linux-arm64-musl@2.11.4': optional: true - '@tauri-apps/cli-linux-riscv64-gnu@2.10.0': + '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': optional: true - '@tauri-apps/cli-linux-x64-gnu@2.10.0': + '@tauri-apps/cli-linux-x64-gnu@2.11.4': optional: true - '@tauri-apps/cli-linux-x64-musl@2.10.0': + '@tauri-apps/cli-linux-x64-musl@2.11.4': optional: true - '@tauri-apps/cli-win32-arm64-msvc@2.10.0': + '@tauri-apps/cli-win32-arm64-msvc@2.11.4': optional: true - '@tauri-apps/cli-win32-ia32-msvc@2.10.0': + '@tauri-apps/cli-win32-ia32-msvc@2.11.4': optional: true - '@tauri-apps/cli-win32-x64-msvc@2.10.0': + '@tauri-apps/cli-win32-x64-msvc@2.11.4': optional: true - '@tauri-apps/cli@2.10.0': + '@tauri-apps/cli@2.11.4': optionalDependencies: - '@tauri-apps/cli-darwin-arm64': 2.10.0 - '@tauri-apps/cli-darwin-x64': 2.10.0 - '@tauri-apps/cli-linux-arm-gnueabihf': 2.10.0 - '@tauri-apps/cli-linux-arm64-gnu': 2.10.0 - '@tauri-apps/cli-linux-arm64-musl': 2.10.0 - '@tauri-apps/cli-linux-riscv64-gnu': 2.10.0 - '@tauri-apps/cli-linux-x64-gnu': 2.10.0 - '@tauri-apps/cli-linux-x64-musl': 2.10.0 - '@tauri-apps/cli-win32-arm64-msvc': 2.10.0 - '@tauri-apps/cli-win32-ia32-msvc': 2.10.0 - '@tauri-apps/cli-win32-x64-msvc': 2.10.0 + '@tauri-apps/cli-darwin-arm64': 2.11.4 + '@tauri-apps/cli-darwin-x64': 2.11.4 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.11.4 + '@tauri-apps/cli-linux-arm64-gnu': 2.11.4 + '@tauri-apps/cli-linux-arm64-musl': 2.11.4 + '@tauri-apps/cli-linux-riscv64-gnu': 2.11.4 + '@tauri-apps/cli-linux-x64-gnu': 2.11.4 + '@tauri-apps/cli-linux-x64-musl': 2.11.4 + '@tauri-apps/cli-win32-arm64-msvc': 2.11.4 + '@tauri-apps/cli-win32-ia32-msvc': 2.11.4 + '@tauri-apps/cli-win32-x64-msvc': 2.11.4 - '@tauri-apps/plugin-dialog@2.6.0': + '@tauri-apps/plugin-dialog@2.7.2': dependencies: - '@tauri-apps/api': 2.10.1 + '@tauri-apps/api': 2.11.1 - '@tauri-apps/plugin-opener@2.5.3': + '@tauri-apps/plugin-opener@2.5.4': dependencies: - '@tauri-apps/api': 2.10.1 + '@tauri-apps/api': 2.11.1 '@tauri-apps/plugin-process@2.3.1': dependencies: - '@tauri-apps/api': 2.10.1 + '@tauri-apps/api': 2.11.1 '@tauri-apps/plugin-updater@2.10.1': dependencies: - '@tauri-apps/api': 2.10.1 + '@tauri-apps/api': 2.11.1 '@testing-library/dom@10.4.1': dependencies: diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b0125cb0..832b9db2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -52,13 +52,13 @@ dependencies = [ [[package]] name = "aes" -version = "0.9.1" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ "cipher 0.5.2", "cpubits", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "zeroize", ] @@ -78,16 +78,16 @@ dependencies = [ [[package]] name = "aes-gcm" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" dependencies = [ "aead 0.6.1", - "aes 0.9.1", + "aes 0.9.3", "cipher 0.5.2", "ctr 0.10.1", + "ctutils", "ghash 0.6.0", - "subtle", "zeroize", ] @@ -97,15 +97,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41ac571010bd60765c56085a4f1d412012a9be2663b1a2f2b19b49318653fd0d" dependencies = [ - "aes 0.9.1", + "aes 0.9.3", "const-oid 0.10.2", ] [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -118,9 +118,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ "alloc-no-stdlib", ] @@ -133,18 +133,18 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] [[package]] name = "anyhow" -version = "1.0.101" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arbitrary" @@ -178,13 +178,13 @@ dependencies = [ [[package]] name = "argon2" -version = "0.6.0-rc.8" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7af50940b73bf4e16c15c448a2b121c63f2d68e3e54b6a8731673cb4aa0cdff5" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" dependencies = [ "base64ct", "blake2", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "password-hash", ] @@ -200,7 +200,7 @@ dependencies = [ "nom 7.1.3", "num-traits", "rusticata-macros", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -211,7 +211,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", "synstructure", ] @@ -223,7 +223,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -252,9 +252,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" dependencies = [ "compression-codecs", "compression-core", @@ -283,9 +283,9 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.13.3" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ "async-task", "concurrent-queue", @@ -362,14 +362,14 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "async-signal" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" dependencies = [ "async-io", "async-lock", @@ -391,13 +391,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 3.0.4", ] [[package]] @@ -410,6 +410,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "asyncband" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94a214ba60d6231afd0e805e3c27c45a1626d9debaa5a5061c45a1ea1b2f1ed0" +dependencies = [ + "hashbrown 0.17.1", + "slab", +] + [[package]] name = "atk" version = "0.18.2" @@ -450,15 +460,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.1" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", "zeroize", @@ -466,9 +476,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.42.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" dependencies = [ "cc", "cmake", @@ -518,6 +528,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -535,6 +551,21 @@ dependencies = [ "sha2 0.11.0", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bit_field" version = "0.10.3" @@ -549,9 +580,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] @@ -570,9 +601,9 @@ dependencies = [ [[package]] name = "blake2" -version = "0.11.0-rc.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "061f1a09225e328e1ffbb378d2d49923c0ca5fee19fb5ac1cc9c1e9d52b93690" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" dependencies = [ "digest 0.11.3", ] @@ -588,9 +619,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", "zeroize", @@ -625,9 +656,9 @@ dependencies = [ [[package]] name = "blocking" -version = "1.6.2" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" dependencies = [ "async-channel", "async-task", @@ -648,9 +679,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.2" +version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -659,25 +690,34 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", ] [[package]] -name = "bumpalo" -version = "3.19.1" +name = "bs58" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "byteorder" @@ -693,9 +733,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -715,7 +755,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cairo-sys-rs", "glib", "libc", @@ -736,9 +776,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.2" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" dependencies = [ "serde_core", ] @@ -763,7 +803,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -796,9 +836,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.55" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", "jobserver", @@ -841,28 +881,28 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cipher 0.5.2", - "cpufeatures 0.3.0", - "rand_core 0.10.0", + "cpufeatures 0.3.1", + "rand_core 0.10.1", "zeroize", ] [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -889,7 +929,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "crypto-common 0.2.2", "inout 0.2.2", "zeroize", @@ -915,9 +955,9 @@ dependencies = [ [[package]] name = "cmov" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] name = "color_quant" @@ -927,9 +967,9 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -999,12 +1039,6 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - [[package]] name = "convert_case" version = "0.10.0" @@ -1016,9 +1050,9 @@ dependencies = [ [[package]] name = "cookie" -version = "0.18.1" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" dependencies = [ "time", "version_check", @@ -1065,11 +1099,11 @@ dependencies = [ [[package]] name = "core-graphics" -version = "0.24.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-graphics-types 0.2.0", "foreign-types 0.5.0", @@ -1093,7 +1127,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.10.1", "libc", ] @@ -1112,9 +1146,9 @@ dependencies = [ [[package]] name = "cpubits" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef0c543070d296ea414df2dd7625d1b24866ce206709d8a4a424f28377f5861" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" [[package]] name = "cpufeatures" @@ -1127,9 +1161,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -1155,9 +1189,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" dependencies = [ "cfg-if", ] @@ -1170,18 +1204,18 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crunchy" @@ -1209,10 +1243,10 @@ checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" dependencies = [ "cpubits", "ctutils", - "getrandom 0.4.1", + "getrandom 0.4.3", "hybrid-array", "num-traits", - "rand_core 0.10.0", + "rand_core 0.10.1", "serdect", "subtle", "zeroize", @@ -1235,9 +1269,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "getrandom 0.4.1", + "getrandom 0.4.3", "hybrid-array", - "rand_core 0.10.0", + "rand_core 0.10.1", ] [[package]] @@ -1252,13 +1286,12 @@ dependencies = [ [[package]] name = "crypto-primes" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21f41f23de7d24cdbda7f0c4d9c0351f99a4ceb258ef30e5c1927af8987ffe5a" +checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" dependencies = [ "crypto-bigint 0.7.5", - "libm", - "rand_core 0.10.0", + "rand_core 0.10.1", ] [[package]] @@ -1282,7 +1315,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff765b99fc49f3116c9a908484486a2b92fd73c48da45c3a69716471c6cc56c6" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cryptoki-sys", "libloading 0.8.9", "log", @@ -1300,19 +1333,15 @@ dependencies = [ [[package]] name = "cssparser" -version = "0.29.6" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "matches", - "phf 0.10.1", - "proc-macro2", - "quote", + "phf", "smallvec", - "syn 1.0.109", ] [[package]] @@ -1322,19 +1351,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "ctor" -version = "0.2.9" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" dependencies = [ - "quote", - "syn 2.0.114", + "ctor-proc-macro", + "dtor", ] +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + [[package]] name = "ctr" version = "0.9.2" @@ -1385,7 +1420,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c906a87e53a36ff795d72e06e8162a83c5436e3ea89e942a9cb9fc083f0a384f" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "curve25519-dalek-derive", "digest 0.11.3", "fiat-crypto 0.3.0", @@ -1402,14 +1437,14 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "darling" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ "darling_core", "darling_macro", @@ -1417,27 +1452,26 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "darling_macro" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -1456,9 +1490,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.10.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "dbus" @@ -1491,9 +1525,9 @@ dependencies = [ [[package]] name = "deflate64" -version = "0.1.11" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "807800ff3288b621186fe0a8f3392c4652068257302709c24efd918c3dffcdc2" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" [[package]] name = "defmt" @@ -1514,7 +1548,7 @@ dependencies = [ "defmt-parser", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -1523,7 +1557,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -1534,7 +1568,7 @@ checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -1546,15 +1580,14 @@ dependencies = [ "const-oid 0.9.6", "der_derive", "flagset", - "pem-rfc7468 0.7.0", "zeroize", ] [[package]] name = "der" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" dependencies = [ "const-oid 0.10.2", "pem-rfc7468 1.0.0", @@ -1582,16 +1615,15 @@ checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "deranged" -version = "0.5.6" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -1603,20 +1635,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", -] - -[[package]] -name = "derive_more" -version = "0.99.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" -dependencies = [ - "convert_case 0.4.0", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -1634,11 +1653,11 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case 0.10.0", + "convert_case", "proc-macro2", "quote", "rustc_version", - "syn 2.0.114", + "syn 2.0.119", "unicode-xid", ] @@ -1669,10 +1688,11 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", + "zeroize", ] [[package]] @@ -1696,19 +1716,13 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "dispatch" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" - [[package]] name = "dispatch2" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2", "libc", "objc2", @@ -1716,22 +1730,22 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 3.0.4", ] [[package]] name = "dlib" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "libloading 0.7.4", + "libloading 0.8.9", ] [[package]] @@ -1754,7 +1768,7 @@ checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -1766,6 +1780,21 @@ dependencies = [ "const-random", ] +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash 0.2.0", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -1790,7 +1819,7 @@ dependencies = [ "crypto-bigint 0.7.5", "crypto-common 0.2.2", "crypto-primes", - "der 0.8.0", + "der 0.8.1", "digest 0.11.3", "rfc6979 0.6.0", "sha2 0.11.0", @@ -1813,6 +1842,21 @@ dependencies = [ "dtoa", ] +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + [[package]] name = "dunce" version = "1.0.5" @@ -1857,7 +1901,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" dependencies = [ - "der 0.8.0", + "der 0.8.1", "digest 0.11.3", "elliptic-curve 0.14.1", "rfc6979 0.6.0", @@ -1905,7 +1949,7 @@ checksum = "1685663e23882cd8517dcbcb1c23a6ebff4433c22dfb681d760219b62cd1b849" dependencies = [ "curve25519-dalek 5.0.0-rc.1", "ed25519 3.0.0", - "rand_core 0.10.0", + "rand_core 0.10.1", "serde", "sha2 0.11.0", "signature 3.0.0", @@ -1915,9 +1959,9 @@ dependencies = [ [[package]] name = "either" -version = "1.15.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "elliptic-curve" @@ -1954,7 +1998,7 @@ dependencies = [ "hybrid-array", "pem-rfc7468 1.0.0", "pkcs8 0.11.0", - "rand_core 0.10.0", + "rand_core 0.10.1", "sec1 0.8.1", "subtle", "zeroize", @@ -1962,14 +2006,14 @@ dependencies = [ [[package]] name = "embed-resource" -version = "3.0.6" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55a075fc573c64510038d7ee9abc7990635863992f83ebc52c8b433b8411a02e" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" dependencies = [ "cc", "memchr", "rustc_version", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "vswhom", "winreg 0.55.0", ] @@ -2004,7 +2048,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -2025,7 +2069,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -2036,9 +2080,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "erased-serde" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" dependencies = [ "serde", "serde_core", @@ -2078,17 +2122,16 @@ dependencies = [ [[package]] name = "error-code" -version = "3.3.2" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -2116,29 +2159,15 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fax" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" -dependencies = [ - "fax_derive", -] - -[[package]] -name = "fax_derive" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" [[package]] name = "fdeflate" @@ -2165,7 +2194,7 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" dependencies = [ - "rand_core 0.10.0", + "rand_core 0.10.1", "subtle", ] @@ -2204,20 +2233,19 @@ dependencies = [ [[package]] name = "filetime" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ "cfg-if", "libc", - "libredox", ] [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "fixedbitset" @@ -2233,13 +2261,13 @@ checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", "libz-sys", - "miniz_oxide", + "miniz_oxide 0.9.1", "zlib-rs", ] @@ -2261,13 +2289,19 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "font-kit" version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c7e611d49285d4c4b2e1727b72cf05353558885cc5252f93707b845dfcaf3d3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "byteorder", "core-foundation 0.9.4", "core-graphics 0.23.2", @@ -2307,13 +2341,13 @@ dependencies = [ [[package]] name = "foreign-types-macros" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 3.0.4", ] [[package]] @@ -2379,21 +2413,11 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -2406,9 +2430,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -2416,15 +2440,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -2433,9 +2457,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -2452,32 +2476,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 3.0.4", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -2490,15 +2514,6 @@ dependencies = [ "slab", ] -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - [[package]] name = "g2gen" version = "1.2.2" @@ -2508,7 +2523,7 @@ dependencies = [ "g2poly", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -2634,7 +2649,7 @@ checksum = "1d12aba7e9dc2c4d54654566dc3dc8383b5cb52e0cfc5754989afe0480d933e3" dependencies = [ "base64 0.22.1", "bytes", - "derive_more 2.1.1", + "derive_more", "eventsource-stream", "futures", "mime_guess", @@ -2665,9 +2680,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "1.3.5" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaf57c49a95fd1fe24b90b3033bee6dc7e8f1288d51494cb44e627c295e38542" +checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e" dependencies = [ "generic-array 0.14.7", "rustversion", @@ -2684,17 +2699,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "getrandom" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.9.0+wasi-snapshot-preview1", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -2704,7 +2708,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] @@ -2717,24 +2721,22 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.4.1" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", - "rand_core 0.10.0", - "wasip2", - "wasip3", + "r-efi 6.0.0", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -2754,7 +2756,8 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" dependencies = [ - "polyval 0.7.1", + "polyval 0.7.3", + "zeroize", ] [[package]] @@ -2805,7 +2808,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "futures-channel", "futures-core", "futures-executor", @@ -2833,7 +2836,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -2848,9 +2851,9 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "gloo-timers" @@ -2905,7 +2908,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" dependencies = [ "ff 0.14.0", - "rand_core 0.10.0", + "rand_core 0.10.1", "subtle", ] @@ -2958,14 +2961,14 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "h2" -version = "0.4.15" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -2973,7 +2976,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.13.0", + "indexmap 2.14.1", "slab", "tokio", "tokio-util", @@ -3020,14 +3023,14 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heapless" @@ -3038,7 +3041,7 @@ dependencies = [ "atomic-polyfill", "hash32", "rustc_version", - "spin 0.9.8", + "spin 0.9.9", "stable_deref_trait", ] @@ -3110,21 +3113,19 @@ dependencies = [ [[package]] name = "html5ever" -version = "0.29.1" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" dependencies = [ "log", - "mac", "markup5ever", - "match_token", ] [[package]] name = "http" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -3132,9 +3133,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -3142,9 +3143,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -3167,9 +3168,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ "ctutils", "subtle", @@ -3179,9 +3180,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -3200,15 +3201,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -3291,12 +3291,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -3304,9 +3305,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -3317,9 +3318,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -3331,16 +3332,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -3351,15 +3353,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -3370,12 +3372,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -3395,9 +3391,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -3445,12 +3441,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -3466,20 +3462,20 @@ dependencies = [ [[package]] name = "inotify" -version = "0.11.0" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "inotify-sys", "libc", ] [[package]] name = "inotify-sys" -version = "0.1.5" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" dependencies = [ "libc", ] @@ -3518,8 +3514,8 @@ checksum = "ae8e22120c32fb4d19ec55fba35015f57095cd95a2e3b732e44457f5915b2ee8" dependencies = [ "num-integer", "num-traits", - "rand 0.10.0", - "rand_core 0.10.0", + "rand 0.10.2", + "rand_core 0.10.1", ] [[package]] @@ -3543,19 +3539,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "iri-string" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" -dependencies = [ - "memchr", - "serde", -] +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "ironrdp" @@ -3638,7 +3624,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb9050999a1e032f4313788ac5a6d06e897a4f672f1de2ed98683001fc1abf56" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "ironrdp-core", "ironrdp-pdu", "ironrdp-svc", @@ -3733,7 +3719,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7493e426b6a8104cd497e518ba7781a9c7fc9a5f58a9cc0c1111e6d66f63bcc" dependencies = [ "bit_field", - "bitflags 2.11.1", + "bitflags 2.13.1", "bitvec", "byteorder", "ironrdp-core", @@ -3761,7 +3747,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19371274ea75ac1edb3431b08b823aad0dba48124c8fe7efbc4c3d8a30a22327" dependencies = [ "base64 0.22.1", - "bitflags 2.11.1", + "bitflags 2.13.1", "futures-util", "http-body-util", "hyper", @@ -3783,18 +3769,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ccd1179a4d106df1930347701388b5c79bc3725fa7dab4438d57db0d9d19347" dependencies = [ "bit_field", - "bitflags 2.11.1", + "bitflags 2.13.1", "byteorder", "der-parser", "ironrdp-core", "ironrdp-error", "md-5 0.10.6", - "num-bigint 0.4.6", + "num-bigint 0.4.8", "num-derive", "num-integer", "num-traits", "pkcs1 0.7.5", - "sha1 0.10.6", + "sha1 0.10.7", "tap", "x509-cert", ] @@ -3837,7 +3823,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24c36b82ab0f7fef2668fb7004008a0f3c100a3d9a7b18bd4495a71aba55b796" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "ironrdp-core", "ironrdp-pdu", ] @@ -3904,9 +3890,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "javascriptcore-rs" @@ -3933,11 +3919,12 @@ dependencies = [ [[package]] name = "jiff" -version = "0.2.32" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" dependencies = [ "defmt", + "jiff-core", "jiff-static", "jiff-tzdb-platform", "js-sys", @@ -3950,21 +3937,31 @@ dependencies = [ ] [[package]] -name = "jiff-static" -version = "0.2.32" +name = "jiff-core" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "jiff-tzdb" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" [[package]] name = "jiff-tzdb-platform" @@ -3984,7 +3981,7 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys 0.3.0", + "jni-sys 0.3.1", "log", "thiserror 1.0.69", "walkdir", @@ -4003,7 +4000,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.18", + "thiserror 2.0.20", "walkdir", "windows-link 0.2.1", ] @@ -4018,14 +4015,17 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "jni-sys" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] [[package]] name = "jni-sys" @@ -4043,28 +4043,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -4101,12 +4100,12 @@ dependencies = [ [[package]] name = "keccak" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", ] [[package]] @@ -4116,7 +4115,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" dependencies = [ "crypto-common 0.2.2", - "rand_core 0.10.0", + "rand_core 0.10.1", ] [[package]] @@ -4125,7 +4124,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "serde", "unicode-segmentation", ] @@ -4141,16 +4140,16 @@ dependencies = [ "log", "secret-service", "security-framework 2.11.1", - "security-framework 3.6.0", + "security-framework 3.7.0", "windows-sys 0.60.2", "zeroize", ] [[package]] name = "kqueue" -version = "1.1.1" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" dependencies = [ "kqueue-sys", "libc", @@ -4158,41 +4157,23 @@ dependencies = [ [[package]] name = "kqueue-sys" -version = "1.0.4" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.13.1", "libc", ] -[[package]] -name = "kuchikiki" -version = "0.8.8-speedreader" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" -dependencies = [ - "cssparser", - "html5ever", - "indexmap 2.13.0", - "selectors", -] - [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin 0.9.8", + "spin 0.9.9", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libappindicator" version = "0.9.0" @@ -4219,15 +4200,15 @@ dependencies = [ [[package]] name = "libbz2-rs-sys" -version = "0.2.2" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libdbus-sys" @@ -4266,13 +4247,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.12" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +checksum = "d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "libc", - "redox_syscall 0.7.4", + "plain", + "redox_syscall 0.9.3", ] [[package]] @@ -4308,15 +4290,15 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lock_api" @@ -4329,9 +4311,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru" @@ -4350,19 +4332,13 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "lzma-rust2" -version = "0.16.2" +version = "0.16.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47bb1e988e6fb779cf720ad431242d3f03167c1b3f2b1aae7f1a94b2495b36ae" +checksum = "ca93e534d1142d1d0dcca6d25fe302508a5dfb40b302802904577725ea0b695b" dependencies = [ - "sha2 0.10.9", + "sha2 0.11.0", ] -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - [[package]] name = "mach2" version = "0.4.3" @@ -4374,27 +4350,13 @@ dependencies = [ [[package]] name = "markup5ever" -version = "0.14.1" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" dependencies = [ "log", - "phf 0.11.3", - "phf_codegen 0.11.3", - "string_cache", - "string_cache_codegen", "tendril", -] - -[[package]] -name = "match_token" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", + "web_atoms", ] [[package]] @@ -4406,12 +4368,6 @@ dependencies = [ "regex-automata", ] -[[package]] -name = "matches" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" - [[package]] name = "md-5" version = "0.10.6" @@ -4443,24 +4399,25 @@ dependencies = [ [[package]] name = "md5" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" +checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" [[package]] name = "mea" -version = "0.6.4" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2640d335e7273dacdcf51044026139b2e269c3bb0dfc3f8cb3496b85e3f6a42c" +checksum = "c709842c4ce65cb91e2666ad5319dfc1efc3af0d34f02075eddca9000d9f8afb" dependencies = [ + "hashbrown 0.17.1", "slab", ] [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memoffset" @@ -4518,6 +4475,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.2" @@ -4526,7 +4493,7 @@ checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "log", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.61.2", ] @@ -4540,7 +4507,7 @@ dependencies = [ "kem", "module-lattice", "pkcs8 0.11.0", - "rand_core 0.10.0", + "rand_core 0.10.1", "sha3 0.11.0", ] @@ -4567,9 +4534,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.17.1" +version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" dependencies = [ "crossbeam-channel", "dpi", @@ -4580,10 +4547,10 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "once_cell", - "png 0.17.16", + "png 0.18.1", "serde", - "thiserror 2.0.18", - "windows-sys 0.60.2", + "thiserror 2.0.20", + "windows-sys 0.61.2", ] [[package]] @@ -4598,7 +4565,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework 3.6.0", + "security-framework 3.7.0", "security-framework-sys", "tempfile", ] @@ -4609,8 +4576,8 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.11.1", - "jni-sys 0.3.0", + "bitflags 2.13.1", + "jni-sys 0.3.1", "log", "ndk-sys", "num_enum", @@ -4618,19 +4585,13 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - [[package]] name = "ndk-sys" version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ - "jni-sys 0.3.0", + "jni-sys 0.3.1", ] [[package]] @@ -4670,7 +4631,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -4679,22 +4640,16 @@ dependencies = [ [[package]] name = "nix" -version = "0.31.2" +version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", ] -[[package]] -name = "nodrop" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" - [[package]] name = "nom" version = "7.1.3" @@ -4720,7 +4675,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "fsevent-sys", "inotify", "kqueue", @@ -4738,7 +4693,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "serde", ] @@ -4767,7 +4722,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" dependencies = [ - "num-bigint 0.4.6", + "num-bigint 0.4.8", "num-complex", "num-integer", "num-iter", @@ -4788,9 +4743,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -4807,7 +4762,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "rand 0.8.8", "smallvec", "zeroize", ] @@ -4823,9 +4778,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -4835,25 +4790,24 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] @@ -4864,7 +4818,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" dependencies = [ - "num-bigint 0.4.6", + "num-bigint 0.4.8", "num-integer", "num-traits", ] @@ -4881,9 +4835,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -4891,14 +4845,14 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -4912,7 +4866,7 @@ dependencies = [ [[package]] name = "nyaterm" -version = "1.2.5" +version = "1.2.6" dependencies = [ "aes 0.8.4", "aes-gcm 0.10.3", @@ -4945,7 +4899,7 @@ dependencies = [ "pbkdf2 0.12.2", "portable-pty", "quick-xml 0.40.1", - "rand 0.8.5", + "rand 0.8.8", "redb", "regex", "reqwest 0.12.28", @@ -4957,7 +4911,7 @@ dependencies = [ "serde", "serde_json", "serialport", - "sha1 0.10.6", + "sha1 0.10.7", "sha2 0.10.9", "sha3 0.10.9", "smallvec", @@ -4971,7 +4925,7 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-single-instance", "tauri-plugin-updater", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tokio", "tokio-socks", @@ -4983,12 +4937,13 @@ dependencies = [ "uuid", "vnc-rs", "webview2-com", + "whoami", "window-vibrancy", "windows 0.61.3", "windows-sys 0.61.2", "x509-cert", "zeroize", - "zip 8.2.0", + "zip 8.6.0", "zmodem2", ] @@ -4996,7 +4951,7 @@ dependencies = [ name = "nyaterm-mcp-protocol" version = "0.1.0" dependencies = [ - "schemars 1.2.1", + "schemars 1.2.2", "serde", "serde_json", ] @@ -5007,9 +4962,9 @@ version = "0.1.0" [[package]] name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", "objc2-exception-helper", @@ -5021,19 +4976,12 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2", - "libc", "objc2", - "objc2-cloud-kit", - "objc2-core-data", "objc2-core-foundation", "objc2-core-graphics", - "objc2-core-image", - "objc2-core-text", - "objc2-core-video", "objc2-foundation", - "objc2-quartz-core", ] [[package]] @@ -5042,7 +4990,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2", "objc2-foundation", ] @@ -5053,7 +5001,6 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ - "bitflags 2.11.1", "objc2", "objc2-foundation", ] @@ -5064,7 +5011,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "dispatch2", "objc2", ] @@ -5075,7 +5022,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "dispatch2", "objc2", "objc2-core-foundation", @@ -5092,31 +5039,28 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-text" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2", "objc2-core-foundation", "objc2-core-graphics", ] -[[package]] -name = "objc2-core-video" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" -dependencies = [ - "bitflags 2.11.1", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-io-surface", -] - [[package]] name = "objc2-encode" version = "4.1.0" @@ -5138,7 +5082,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2", "libc", "objc2", @@ -5151,17 +5095,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.11.1", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-javascript-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" -dependencies = [ + "bitflags 2.13.1", "objc2", "objc2-core-foundation", ] @@ -5172,7 +5106,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2", "objc2-app-kit", "objc2-foundation", @@ -5184,32 +5118,40 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2", "objc2-core-foundation", "objc2-foundation", ] -[[package]] -name = "objc2-security" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" -dependencies = [ - "bitflags 2.11.1", - "objc2", - "objc2-core-foundation", -] - [[package]] name = "objc2-ui-kit" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", + "block2", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", "objc2-foundation", ] @@ -5219,14 +5161,12 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2", "objc2", "objc2-app-kit", "objc2-core-foundation", "objc2-foundation", - "objc2-javascript-core", - "objc2-security", ] [[package]] @@ -5252,21 +5192,20 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "open" -version = "5.3.3" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +checksum = "ade3be4664bc1ef537ce133015f04c176b737815c2ba9fd60edf212d6e90dd55" dependencies = [ "dunce", "is-wsl", "libc", - "pathdiff", ] [[package]] name = "opendal" -version = "0.58.0" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77d02c6564e376d3670aaf66ad886cd34f83c0aca407b6364777c624a63e0e2d" +checksum = "33dbff14cc9bb085224256d6a81289d2f3202e85b06f408d42534b42162a4231" dependencies = [ "opendal-core", "opendal-http-transport-reqwest", @@ -5282,19 +5221,19 @@ dependencies = [ [[package]] name = "opendal-core" -version = "0.58.0" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8564bd76b75d2aea59178cb5b6e9f770be1da73b1bca062720ec151cc56215a" +checksum = "48dbcef97d3eb7591db2c18d5cae95c836bcce07359b98d98dd6f4e861eb77b7" dependencies = [ "anyhow", - "base64 0.22.1", + "asyncband", + "base64 0.23.1", "bytes", "futures", "http", "jiff", "log", "md-5 0.11.0", - "mea", "percent-encoding", "quick-xml 0.41.0", "reqsign-core", @@ -5308,9 +5247,9 @@ dependencies = [ [[package]] name = "opendal-http-transport-reqwest" -version = "0.58.0" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b7fd001b204df76be5d2b3f7a81c48b5cf25b28e2cfb3b8a819bb89cdc0ea3a" +checksum = "85663452ea32bbc17e8f79ab29788c846d116ec7de31451be9c787e462dcb36c" dependencies = [ "bytes", "futures", @@ -5322,9 +5261,9 @@ dependencies = [ [[package]] name = "opendal-layer-retry" -version = "0.58.0" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2df70875ab7fd6f80720d4787c49c70883cef0d81bfae947ecba88b8d1cd62e" +checksum = "e94db301964a25366090484d61e6da16d5979cc8faf02c3e12210dc74fafed38" dependencies = [ "backon", "log", @@ -5333,9 +5272,9 @@ dependencies = [ [[package]] name = "opendal-layer-timeout" -version = "0.58.0" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18f1bb30ab396a617ef884863b27ae91fbe966e312ce69876fa41584ab0fc5c6" +checksum = "08956ddda07465449bfd48825f4f0f25e0351278ac974eaa659895d9d74f2c80" dependencies = [ "opendal-core", "tokio", @@ -5343,9 +5282,9 @@ dependencies = [ [[package]] name = "opendal-layer-tracing" -version = "0.58.0" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04d866ce663c56a327ce30ee8fca1aa20250d6c7c06264cd4e2f4fb1d6a2e022" +checksum = "415e35e6137b02c80cdd69be9e7e72f9bb170fc9249dfb08eac9ca5f87e5d3d6" dependencies = [ "futures", "http", @@ -5355,14 +5294,14 @@ dependencies = [ [[package]] name = "opendal-service-aliyun-drive" -version = "0.58.0" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af933977128255600dc7161eef636e0f1f99c035c1d65ce825e578ba388d8805" +checksum = "c7c3e551e25d1c182be77a7c501eb3e0a9217fc409fca6f88125167083daa304" dependencies = [ + "asyncband", "bytes", "http", "log", - "mea", "opendal-core", "serde", "serde_json", @@ -5370,14 +5309,14 @@ dependencies = [ [[package]] name = "opendal-service-gdrive" -version = "0.58.0" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5ba5d3c6b19188ef0a229cc22d07f078dd0de388c3246e1370d7b2c7563a765" +checksum = "bf817a82a81423bdda3c980e5d5b076f07364a71398dbf038d6220bb4f55b529" dependencies = [ + "asyncband", "bytes", "http", "log", - "mea", "opendal-core", "serde", "serde_json", @@ -5385,14 +5324,14 @@ dependencies = [ [[package]] name = "opendal-service-onedrive" -version = "0.58.0" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e425f33e5eff2207ffd232fa1172531e1bb89b5968a57d9be903ec4e5678a5ff" +checksum = "d2d251202faa1d3d7e05a02f4fcb7e56a8770dfcd69a5e5fdebc567dabd5133c" dependencies = [ + "asyncband", "bytes", "http", "log", - "mea", "opendal-core", "serde", "serde_json", @@ -5401,11 +5340,11 @@ dependencies = [ [[package]] name = "opendal-service-s3" -version = "0.58.0" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "750d9cc8588c19b27c4c2c5d06d7eb6f2bff62549c48652f1f9a7603cd872377" +checksum = "c64335f9f24ccb62ac36f1d976342b48611a75ba61979813a4f78a4ebd94de42" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "crc-fast", "http", @@ -5422,15 +5361,15 @@ dependencies = [ [[package]] name = "opendal-service-webdav" -version = "0.58.0" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48e7527c793412d4b129f334258af73689e144cb91610cabfbdb47d410248c08" +checksum = "1fa9aec39b39efa0367020732991260373492ffb0273259591ec362b664f455f" dependencies = [ "anyhow", + "asyncband", "bytes", "http", "log", - "mea", "opendal-core", "quick-xml 0.41.0", "serde", @@ -5438,15 +5377,14 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.75" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if", "foreign-types 0.3.2", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -5459,7 +5397,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -5470,9 +5408,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.111" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -5527,7 +5465,7 @@ dependencies = [ "objc2-osa-kit", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -5619,9 +5557,9 @@ dependencies = [ "delegate", "futures", "log", - "rand 0.10.0", + "rand 0.10.2", "sha2 0.11.0", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "windows 0.62.2", "windows-strings 0.5.1", @@ -5696,12 +5634,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - [[package]] name = "pathfinder_geometry" version = "0.5.1" @@ -5714,9 +5646,9 @@ dependencies = [ [[package]] name = "pathfinder_simd" -version = "0.5.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf9027960355bf3afff9841918474a81a5f972ac6d226d518060bba758b5ad57" +checksum = "4500030c302e4af1d423f36f3b958d1aecb6c04184356ed5a833bf6b60435777" dependencies = [ "rustc_version", ] @@ -5773,7 +5705,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset", "hashbrown 0.15.5", - "indexmap 2.13.0", + "indexmap 2.14.1", ] [[package]] @@ -5788,144 +5720,63 @@ dependencies = [ [[package]] name = "phf" -version = "0.8.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_shared 0.8.0", -] - -[[package]] -name = "phf" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" -dependencies = [ - "phf_macros 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros 0.11.3", - "phf_shared 0.11.3", + "phf_macros", + "phf_shared", + "serde", ] [[package]] name = "phf_codegen" -version = "0.8.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", + "phf_generator", + "phf_shared", ] [[package]] name = "phf_generator" -version = "0.8.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ - "phf_shared 0.8.0", - "rand 0.7.3", -] - -[[package]] -name = "phf_generator" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" -dependencies = [ - "phf_shared 0.10.0", - "rand 0.8.5", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.5", + "fastrand", + "phf_shared", ] [[package]] name = "phf_macros" -version = "0.10.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", + "phf_generator", + "phf_shared", "proc-macro2", "quote", - "syn 1.0.109", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "phf_shared" -version = "0.8.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher 1.0.2", + "siphasher", ] [[package]] name = "picky" version = "7.0.0-rc.25" dependencies = [ - "aes 0.9.1", - "aes-gcm 0.11.0", + "aes 0.9.3", + "aes-gcm 0.11.1", "aes-kw", "base64 0.22.1", "cbc 0.2.1", @@ -5951,8 +5802,8 @@ dependencies = [ "picky-asn1-x509", "pkcs1 0.8.0-rc.4", "primeorder 0.14.0-rc.15", - "rand 0.10.0", - "rand_core 0.10.0", + "rand 0.10.2", + "rand_core 0.10.1", "rc2", "rsa 0.10.0-rc.18", "rustcrypto-ff", @@ -5963,7 +5814,7 @@ dependencies = [ "sha1 0.11.0", "sha2 0.11.0", "sha3 0.12.0", - "thiserror 2.0.18", + "thiserror 2.0.20", "x25519-dalek", "zeroize", ] @@ -6014,7 +5865,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d188f3192356068dbdba54bddbca6fd0f7a09565d3861eeb8efe1ab77ae8e97" dependencies = [ - "aes 0.9.1", + "aes 0.9.3", "block-padding 0.4.2", "byteorder", "cbc 0.2.1", @@ -6028,19 +5879,19 @@ dependencies = [ "picky-asn1", "picky-asn1-der", "picky-asn1-x509", - "rand 0.10.0", - "rand_core 0.10.0", + "rand 0.10.2", + "rand_core 0.10.1", "serde", "sha1 0.11.0", - "thiserror 2.0.18", + "thiserror 2.0.20", "uuid", ] [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -6050,9 +5901,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "piper" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", "fastrand", @@ -6076,37 +5927,23 @@ version = "0.8.0-rc.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" dependencies = [ - "der 0.8.0", + "der 0.8.1", "spki 0.8.0", ] [[package]] name = "pkcs5" -version = "0.7.1" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" +checksum = "63d440a804ec8d6fafbb6b84471e013286658d373248927692ab3366686220ca" dependencies = [ - "aes 0.8.4", - "cbc 0.1.2", - "der 0.7.10", - "pbkdf2 0.12.2", - "scrypt 0.11.0", - "sha2 0.10.9", - "spki 0.7.3", -] - -[[package]] -name = "pkcs5" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "279a91971a1d8eb1260a30938eae3be9cb67b472dffecb222fbbbe2fd2dc1453" -dependencies = [ - "aes 0.9.1", + "aes 0.9.3", + "aes-gcm 0.11.1", "cbc 0.2.1", - "der 0.8.0", + "der 0.8.1", "pbkdf2 0.13.0", - "rand_core 0.10.0", - "scrypt 0.12.0", + "rand_core 0.10.1", + "scrypt", "sha2 0.11.0", "spki 0.8.0", ] @@ -6118,8 +5955,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ "der 0.7.10", - "pkcs5 0.7.1", - "rand_core 0.6.4", "spki 0.7.3", ] @@ -6129,27 +5964,33 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der 0.8.0", - "pkcs5 0.8.0", - "rand_core 0.10.0", + "der 0.8.1", + "pkcs5", + "rand_core 0.10.1", "spki 0.8.0", ] [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "plist" -version = "1.8.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", - "indexmap 2.13.0", - "quick-xml 0.38.4", + "indexmap 2.14.1", + "quick-xml 0.41.0", "serde", "time", ] @@ -6164,7 +6005,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -6173,11 +6014,11 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -6207,11 +6048,11 @@ dependencies = [ [[package]] name = "poly1305" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00baa632505d05512f48a963e16051c54fda9a95cc9acea1a4e3c90991c4a2e" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" dependencies = [ - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "universal-hash 0.6.1", "zeroize", ] @@ -6230,20 +6071,21 @@ dependencies = [ [[package]] name = "polyval" -version = "0.7.1" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dfc63250416fea14f5749b90725916a6c903f599d51cb635aa7a52bfd03eede" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" dependencies = [ "cpubits", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "universal-hash 0.6.1", + "zeroize", ] [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" @@ -6277,9 +6119,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -6311,16 +6153,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.114", -] - [[package]] name = "primefield" version = "0.14.0" @@ -6330,7 +6162,7 @@ dependencies = [ "crypto-bigint 0.7.5", "crypto-common 0.2.2", "ff 0.14.0", - "rand_core 0.10.0", + "rand_core 0.10.1", "subtle", "zeroize", ] @@ -6379,11 +6211,11 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.23.10+spec-1.0.0", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] @@ -6410,26 +6242,20 @@ dependencies = [ "version_check", ] -[[package]] -name = "proc-macro-hack" -version = "0.5.20+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" - [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "pxfm" -version = "0.1.28" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" [[package]] name = "quick-error" @@ -6437,24 +6263,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", -] - -[[package]] -name = "quick-xml" -version = "0.39.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" -dependencies = [ - "memchr", -] - [[package]] name = "quick-xml" version = "0.40.1" @@ -6462,7 +6270,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2474bd2e5029e7ccb6abb2ba48cf2383a333851dedf495901544281590c7da7f" dependencies = [ "memchr", - "serde", ] [[package]] @@ -6489,7 +6296,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -6497,21 +6304,22 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.15" +version = "0.11.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.5", + "rand 0.10.2", + "rand_pcg", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -6533,9 +6341,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.44" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -6546,6 +6354,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radium" version = "0.7.0" @@ -6554,23 +6368,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.7.3" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" -dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", - "rand_pcg", -] - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -6589,23 +6389,13 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", - "getrandom 0.4.1", - "rand_core 0.10.0", -] - -[[package]] -name = "rand_chacha" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" -dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -6628,15 +6418,6 @@ dependencies = [ "rand_core 0.9.5", ] -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" -dependencies = [ - "getrandom 0.1.16", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -6657,26 +6438,17 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" - -[[package]] -name = "rand_hc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" -dependencies = [ - "rand_core 0.5.1", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rand_pcg" -version = "0.2.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core 0.5.1", + "rand_core 0.10.1", ] [[package]] @@ -6696,9 +6468,9 @@ dependencies = [ [[package]] name = "redb" -version = "4.1.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e925444704b5f17d32bf42f5b6e2df050bceebc3dcd6e71cc73dafe8092e839" +checksum = "de6c3b63e007e90ce536ec2ae4690826136a20ec8dbbbb400daef1bb999d2e36" dependencies = [ "libc", ] @@ -6709,16 +6481,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] name = "redox_syscall" -version = "0.7.4" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] @@ -6729,34 +6501,34 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "ref-cast" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 3.0.4", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -6766,9 +6538,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -6777,24 +6549,23 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.9" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] -name = "reqsign-aws-v4" -version = "3.0.1" +name = "reqsign-aws-core" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b75624bd8a466e37ddc0a7b6c33ac859a85347c153a916e1dd9d0b68338f74a" +checksum = "bac4749b7dfa7bfaccd01eb03e9dc795ed37e3f20d6f0f38e2c67ee85ad6bc86" dependencies = [ - "anyhow", "bytes", "form_urlencoded", "hex", "http", "log", "percent-encoding", - "quick-xml 0.40.1", + "quick-xml 0.41.0", "reqsign-core", "rust-ini", "serde", @@ -6804,25 +6575,37 @@ dependencies = [ ] [[package]] -name = "reqsign-core" -version = "3.0.1" +name = "reqsign-aws-v4" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5fa5cb48808693614d1701fcd3db0b30fa292e0f18e122ae068b6d32eaeed3f" +checksum = "ff250f0fd0b913fbd565e405acc553da0f13bde30bfb5403178c9d0313cdc15f" +dependencies = [ + "bytes", + "http", + "log", + "quick-xml 0.41.0", + "reqsign-aws-core", + "reqsign-core", + "serde", +] + +[[package]] +name = "reqsign-core" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff052daffb0599681c50f85c59e7236438976efe991ab864edd9f3b235501a0f" dependencies = [ "anyhow", - "base64 0.22.1", + "base64 0.23.1", "bytes", - "form_urlencoded", "futures", "hex", "hmac 0.13.0", "http", "jiff", "log", + "mea", "percent-encoding", - "rsa 0.9.10", - "serde", - "serde_json", "sha1 0.11.0", "sha2 0.11.0", "windows-sys 0.61.2", @@ -6830,9 +6613,9 @@ dependencies = [ [[package]] name = "reqsign-file-read-tokio" -version = "3.0.1" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a4b6f3a3fd29ffcc99a90aec585a65217783badfd73acddf847b63ae683bda9" +checksum = "b3235df90a6bca681aa47dd86f2393d122a6d77042aa8a7c81e218cd45c5bfc0" dependencies = [ "anyhow", "reqsign-core", @@ -7026,7 +6809,7 @@ dependencies = [ "digest 0.11.3", "pkcs1 0.8.0-rc.4", "pkcs8 0.11.0", - "rand_core 0.10.0", + "rand_core 0.10.1", "sha2 0.11.0", "signature 3.0.0", "spki 0.8.0", @@ -7037,8 +6820,8 @@ dependencies = [ name = "russh" version = "0.62.1" dependencies = [ - "aes 0.9.1", - "bitflags 2.11.1", + "aes 0.9.3", + "bitflags 2.13.1", "block-padding 0.4.2", "byteorder", "bytes", @@ -7049,7 +6832,7 @@ dependencies = [ "curve25519-dalek 5.0.0-rc.1", "data-encoding", "delegate", - "der 0.8.0", + "der 0.8.1", "des", "digest 0.11.3", "ecdsa 0.17.0", @@ -7058,36 +6841,36 @@ dependencies = [ "enum_dispatch", "flate2", "futures", - "generic-array 1.3.5", - "getrandom 0.4.1", + "generic-array 1.4.5", + "getrandom 0.4.3", "ghash 0.6.0", "hex-literal", "hmac 0.13.0", "inout 0.2.2", "internal-russh-num-bigint", - "keccak 0.2.0", + "keccak 0.2.2", "log", "md5", "ml-kem", "module-lattice", - "num-bigint 0.4.6", + "num-bigint 0.4.8", "p256 0.14.0-rc.15", "p384 0.14.0-rc.15", "p521 0.14.0-rc.15", "pageant", "pbkdf2 0.13.0", "pkcs1 0.8.0-rc.4", - "pkcs5 0.8.0", + "pkcs5", "pkcs8 0.11.0", - "polyval 0.7.1", - "rand 0.10.0", - "rand_core 0.10.0", + "polyval 0.7.3", + "rand 0.10.2", + "rand_core 0.10.1", "ring", "rsa 0.10.0-rc.18", "russh-cryptovec", "russh-util", "salsa20 0.11.0", - "scrypt 0.12.0", + "scrypt", "sec1 0.8.1", "sha1 0.11.0", "sha2 0.11.0", @@ -7097,7 +6880,7 @@ dependencies = [ "ssh-encoding 0.3.0", "ssh-key 0.7.0-rc.11", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "typenum", "universal-hash 0.6.1", @@ -7109,7 +6892,7 @@ name = "russh-cryptovec" version = "0.62.0" dependencies = [ "log", - "nix 0.31.2", + "nix 0.31.3", "ssh-encoding 0.3.0", "windows-sys 0.61.2", ] @@ -7118,7 +6901,7 @@ dependencies = [ name = "russh-sftp" version = "2.3.0" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "bytes", "chrono", "dashmap", @@ -7127,7 +6910,7 @@ dependencies = [ "log", "serde", "serde_bytes", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-util", "wasm-bindgen-futures", @@ -7175,7 +6958,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd2a8adb347447693cd2ba0d218c4b66c62da9b0a5672b17b981e4291ec65ff6" dependencies = [ "bitvec", - "rand_core 0.10.0", + "rand_core 0.10.1", "rustcrypto-ff_derive", "subtle", ] @@ -7201,7 +6984,7 @@ version = "0.14.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "369f9b61aa45933c062c9f6b5c3c50ab710687eca83dd3802653b140b43f85ed" dependencies = [ - "rand_core 0.10.0", + "rand_core 0.10.1", "rustcrypto-ff", "subtle", ] @@ -7217,11 +7000,11 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "errno 0.3.14", "libc", "linux-raw-sys", @@ -7230,9 +7013,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", "log", @@ -7252,14 +7035,14 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.6.0", + "security-framework 3.7.0", ] [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -7280,7 +7063,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework 3.6.0", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -7294,9 +7077,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -7306,9 +7089,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rusty-leveldb" @@ -7321,7 +7104,7 @@ dependencies = [ "errno 0.2.8", "fs2", "integer-encoding", - "rand 0.8.5", + "rand 0.8.8", "snap", ] @@ -7361,9 +7144,9 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] @@ -7397,13 +7180,13 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", - "schemars_derive 1.2.1", + "schemars_derive 1.2.2", "serde", "serde_json", ] @@ -7416,20 +7199,20 @@ checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" dependencies = [ "proc-macro2", "quote", - "serde_derive_internals", - "syn 2.0.114", + "serde_derive_internals 0.29.1", + "syn 2.0.119", ] [[package]] name = "schemars_derive" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" dependencies = [ "proc-macro2", "quote", - "serde_derive_internals", - "syn 2.0.114", + "serde_derive_internals 0.30.0", + "syn 3.0.4", ] [[package]] @@ -7438,17 +7221,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "scrypt" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" -dependencies = [ - "pbkdf2 0.12.2", - "salsa20 0.10.2", - "sha2 0.10.9", -] - [[package]] name = "scrypt" version = "0.12.0" @@ -7483,7 +7255,7 @@ checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ "base16ct 1.0.0", "ctutils", - "der 0.8.0", + "der 0.8.1", "hybrid-array", "subtle", "zeroize", @@ -7511,7 +7283,7 @@ dependencies = [ "hkdf 0.12.4", "num", "once_cell", - "rand 0.8.5", + "rand 0.8.8", "serde", "sha2 0.10.9", "zbus 4.4.0", @@ -7523,7 +7295,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -7532,11 +7304,11 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.6.0" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -7555,27 +7327,28 @@ dependencies = [ [[package]] name = "selectors" -version = "0.24.0" +version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.13.1", "cssparser", - "derive_more 0.99.20", - "fxhash", + "derive_more", "log", - "phf 0.8.0", - "phf_codegen 0.8.0", + "new_debug_unreachable", + "phf", + "phf_codegen", "precomputed-hash", + "rustc-hash", "servo_arc", "smallvec", ] [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" dependencies = [ "serde", "serde_core", @@ -7583,9 +7356,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -7615,22 +7388,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 3.0.4", ] [[package]] @@ -7641,14 +7414,25 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -7659,13 +7443,13 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 3.0.4", ] [[package]] @@ -7679,9 +7463,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.4" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] @@ -7700,17 +7484,19 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.16.1" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" dependencies = [ "base64 0.22.1", + "bs58", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.13.0", + "indexmap 2.14.1", + "jiff", "schemars 0.9.0", - "schemars 1.2.1", + "schemars 1.2.2", "serde_core", "serde_json", "serde_with_macros", @@ -7719,21 +7505,21 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.16.1" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "serdect" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9af4a3e75ebd5599b30d4de5768e00b5095d518a79fefc3ecbaf77e665d1ec06" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" dependencies = [ "base16ct 1.0.0", "serde", @@ -7800,16 +7586,16 @@ checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "serialport" -version = "4.9.0" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3" +checksum = "6a2f4ac56b5d3af3c40fbbee17be96d532cba02fa5853926aacdb77d926272ab" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if", "core-foundation 0.10.1", "core-foundation-sys", @@ -7824,19 +7610,18 @@ dependencies = [ [[package]] name = "servo_arc" -version = "0.2.0" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" dependencies = [ - "nodrop", "stable_deref_trait", ] [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -7850,7 +7635,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -7872,7 +7657,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -7893,7 +7678,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" dependencies = [ "digest 0.11.3", - "keccak 0.2.0", + "keccak 0.2.2", ] [[package]] @@ -7903,7 +7688,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" dependencies = [ "digest 0.11.3", - "keccak 0.2.0", + "keccak 0.2.2", "sponge-cursor", ] @@ -7934,9 +7719,9 @@ checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -7965,14 +7750,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" dependencies = [ "digest 0.11.3", - "rand_core 0.10.0", + "rand_core 0.10.1", ] [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" @@ -7992,15 +7777,9 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" -version = "0.3.11" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" - -[[package]] -name = "siphasher" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -8010,15 +7789,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "snap" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" +checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886" [[package]] name = "socket2" @@ -8080,9 +7859,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] @@ -8110,7 +7889,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der 0.8.0", + "der 0.8.1", ] [[package]] @@ -8136,13 +7915,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d801accda99469cde6d73da741422610fdf6508a72d9a69d1b55cb241c720597" dependencies = [ "aead 0.6.1", - "aes 0.9.1", - "aes-gcm 0.11.0", + "aes 0.9.3", + "aes-gcm 0.11.1", "chacha20", "cipher 0.5.2", "ctutils", "des", - "poly1305 0.9.0", + "poly1305 0.9.1", "ssh-encoding 0.3.0", "zeroize", ] @@ -8210,7 +7989,7 @@ dependencies = [ "p256 0.14.0-rc.15", "p384 0.14.0-rc.15", "p521 0.14.0-rc.15", - "rand_core 0.10.0", + "rand_core 0.10.1", "rsa 0.10.0-rc.18", "sec1 0.8.1", "sha1 0.11.0", @@ -8227,7 +8006,7 @@ version = "0.21.0" dependencies = [ "async-dnssd", "async-recursion", - "bitflags 2.11.1", + "bitflags 2.13.1", "bytemuck", "byteorder", "cfg-if", @@ -8258,8 +8037,8 @@ dependencies = [ "pkcs8 0.11.0", "primefield", "primeorder 0.14.0-rc.15", - "rand 0.10.0", - "rand_core 0.10.0", + "rand 0.10.2", + "rand_core 0.10.1", "rsa 0.10.0-rc.18", "rustcrypto-ff", "rustcrypto-ff_derive", @@ -8295,25 +8074,24 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "string_cache" -version = "0.8.9" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared 0.11.3", + "phf_shared", "precomputed-hash", - "serde", ] [[package]] name = "string_cache_codegen" -version = "0.5.4" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", + "phf_generator", + "phf_shared", "proc-macro2", "quote", ] @@ -8351,7 +8129,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -8362,15 +8140,21 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "swift-rs" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +checksum = "e45c444e496845d3f2a351146bff59aae4975b2280238df1dfaa0c7d1846f38e" dependencies = [ "base64 0.21.7", "serde", "serde_json", ] +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "1.0.109" @@ -8384,9 +8168,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.114" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -8410,7 +8205,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -8419,7 +8214,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -8449,35 +8244,35 @@ dependencies = [ [[package]] name = "tao" -version = "0.34.5" +version = "0.35.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2", "core-foundation 0.10.1", - "core-graphics 0.24.0", + "core-graphics 0.25.0", "crossbeam-channel", - "dispatch", + "dbus", + "dispatch2", "dlopen2", "dpi", "gdkwayland-sys", "gdkx11-sys", "gtk", "jni 0.21.1", - "lazy_static", "libc", "log", "ndk", - "ndk-context", "ndk-sys", "objc2", "objc2-app-kit", "objc2-foundation", + "objc2-ui-kit", "once_cell", "parking_lot", + "percent-encoding", "raw-window-handle", - "scopeguard", "tao-macros", "unicode-segmentation", "url", @@ -8489,13 +8284,13 @@ dependencies = [ [[package]] name = "tao-macros" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -8506,9 +8301,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tar" -version = "0.4.45" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" dependencies = [ "filetime", "libc", @@ -8523,9 +8318,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.10.2" +version = "2.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "463ae8677aa6d0f063a900b9c41ecd4ac2b7ca82f0b058cc4491540e55b20129" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" dependencies = [ "anyhow", "bytes", @@ -8563,7 +8358,7 @@ dependencies = [ "tauri-runtime", "tauri-runtime-wry", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tray-icon", "url", @@ -8575,9 +8370,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.5.5" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca7bd893329425df750813e95bd2b643d5369d929438da96d5bbb7cc2c918f74" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" dependencies = [ "anyhow", "cargo_toml", @@ -8591,15 +8386,14 @@ dependencies = [ "serde_json", "tauri-utils", "tauri-winres", - "toml 0.9.12+spec-1.1.0", "walkdir", ] [[package]] name = "tauri-codegen" -version = "2.5.4" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aac423e5859d9f9ccdd32e3cf6a5866a15bedbf25aa6630bcb2acde9468f6ae3" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" dependencies = [ "base64 0.22.1", "brotli", @@ -8613,9 +8407,9 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "syn 2.0.114", + "syn 2.0.119", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "url", "uuid", @@ -8624,23 +8418,23 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.5.4" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b6a1bd2861ff0c8766b1d38b32a6a410f6dc6532d4ef534c47cfb2236092f59" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", "tauri-codegen", "tauri-utils", ] [[package]] name = "tauri-plugin" -version = "2.5.3" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692a77abd8b8773e107a42ec0e05b767b8d2b7ece76ab36c6c3947e34df9f53f" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" dependencies = [ "anyhow", "glob", @@ -8649,7 +8443,6 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "toml 0.9.12+spec-1.1.0", "walkdir", ] @@ -8667,7 +8460,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "url", "windows-registry 0.5.3", @@ -8676,9 +8469,9 @@ dependencies = [ [[package]] name = "tauri-plugin-dialog" -version = "2.6.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b" +checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" dependencies = [ "log", "raw-window-handle", @@ -8688,19 +8481,21 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-plugin-fs", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", ] [[package]] name = "tauri-plugin-fs" -version = "2.4.5" +version = "2.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" dependencies = [ "anyhow", "dunce", "glob", + "log", + "objc2-foundation", "percent-encoding", "schemars 0.8.22", "serde", @@ -8709,16 +8504,16 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-utils", - "thiserror 2.0.18", - "toml 0.9.12+spec-1.1.0", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", "url", ] [[package]] name = "tauri-plugin-opener" -version = "2.5.3" +version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" dependencies = [ "dunce", "glob", @@ -8730,10 +8525,10 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", "windows 0.61.3", - "zbus 5.13.2", + "zbus 5.19.0", ] [[package]] @@ -8748,18 +8543,19 @@ dependencies = [ [[package]] name = "tauri-plugin-single-instance" -version = "2.4.2" +version = "2.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" +checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c" dependencies = [ "serde", "serde_json", "tauri", "tauri-plugin-deep-link", - "thiserror 2.0.18", + "thiserror 2.0.20", + "tokio", "tracing", "windows-sys 0.60.2", - "zbus 5.13.2", + "zbus 5.19.0", ] [[package]] @@ -8786,7 +8582,7 @@ dependencies = [ "tauri", "tauri-plugin", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tokio", "url", @@ -8796,9 +8592,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.10.0" +version = "2.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b885ffeac82b00f1f6fd292b6e5aabfa7435d537cef57d11e38a489956535651" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" dependencies = [ "cookie", "dpi", @@ -8812,7 +8608,7 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", "webkit2gtk", "webview2-com", @@ -8821,9 +8617,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.10.0" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5204682391625e867d16584fedc83fc292fb998814c9f7918605c789cd876314" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", @@ -8831,7 +8627,6 @@ dependencies = [ "log", "objc2", "objc2-app-kit", - "objc2-foundation", "once_cell", "percent-encoding", "raw-window-handle", @@ -8848,24 +8643,24 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.8.2" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcd169fccdff05eff2c1033210b9b94acd07a47e6fa9a3431cf09cfd4f01c87e" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" dependencies = [ "anyhow", "brotli", "cargo_metadata", "ctor", + "dom_query", "dunce", "glob", - "html5ever", "http", "infer", "json-patch", - "kuchikiki", "log", "memchr", - "phf 0.11.3", + "phf", + "plist", "proc-macro2", "quote", "regex", @@ -8876,8 +8671,8 @@ dependencies = [ "serde_json", "serde_with", "swift-rs", - "thiserror 2.0.18", - "toml 0.9.12+spec-1.1.0", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", "url", "urlpattern", "uuid", @@ -8886,23 +8681,23 @@ dependencies = [ [[package]] name = "tauri-winres" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ "dunce", "embed-resource", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", ] [[package]] name = "tempfile" -version = "3.25.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.1", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -8910,13 +8705,11 @@ dependencies = [ [[package]] name = "tendril" -version = "0.4.3" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" dependencies = [ - "futf", - "mac", - "utf-8", + "new_debug_unreachable", ] [[package]] @@ -8939,11 +8732,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.20", ] [[package]] @@ -8954,25 +8747,25 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 3.0.4", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] @@ -8993,12 +8786,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", "js-sys", "libc", "num-conv", @@ -9011,15 +8803,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -9036,9 +8828,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -9077,14 +8869,14 @@ checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -9098,13 +8890,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 3.0.4", ] [[package]] @@ -9129,9 +8921,9 @@ dependencies = [ [[package]] name = "tokio-socks" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" dependencies = [ "either", "futures-util", @@ -9141,9 +8933,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -9170,15 +8962,16 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-io", "futures-sink", "futures-util", + "libc", "pin-project-lite", "tokio", ] @@ -9201,13 +8994,28 @@ version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.1", "serde_core", - "serde_spanned 1.0.4", + "serde_spanned 1.1.1", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 0.7.14", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap 2.14.1", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", ] [[package]] @@ -9228,13 +9036,22 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.1", "toml_datetime 0.6.3", "winnow 0.5.40", ] @@ -9245,7 +9062,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.1", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.3", @@ -9254,30 +9071,30 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.23.10+spec-1.0.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ - "indexmap 2.13.0", - "toml_datetime 0.7.5+spec-1.1.0", + "indexmap 2.14.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 0.7.14", + "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.0.7+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "247eaa3197818b831697600aadf81514e577e0cba5eab10f7e064e78ae154df1" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow 0.7.14", + "winnow 1.0.4", ] [[package]] name = "toml_writer" -version = "1.0.6+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tower" @@ -9296,25 +9113,25 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", - "bitflags 2.11.1", + "bitflags 2.13.1", "bytes", "futures-core", "futures-util", "http", "http-body", "http-body-util", - "iri-string", "pin-project-lite", "tokio", "tokio-util", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -9343,12 +9160,13 @@ dependencies = [ [[package]] name = "tracing-appender" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", - "thiserror 2.0.18", + "symlink", + "thiserror 2.0.20", "time", "tracing-subscriber", ] @@ -9361,7 +9179,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -9397,9 +9215,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", @@ -9419,9 +9237,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.21.3" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" dependencies = [ "crossbeam-channel", "dirs", @@ -9433,10 +9251,10 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation", "once_cell", - "png 0.17.16", + "png 0.18.1", "serde", - "thiserror 2.0.18", - "windows-sys 0.60.2", + "thiserror 2.0.20", + "windows-sys 0.61.2", ] [[package]] @@ -9471,8 +9289,8 @@ dependencies = [ "rand 0.9.5", "rustls", "rustls-pki-types", - "sha1 0.10.6", - "thiserror 2.0.18", + "sha1 0.10.7", + "thiserror 2.0.20", ] [[package]] @@ -9495,22 +9313,22 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "uds_windows" -version = "1.1.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset 0.9.1", "tempfile", - "winapi", + "windows-sys 0.61.2", ] [[package]] name = "unescaper" -version = "0.1.8" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e" +checksum = "7285e83a80ce76f5e7bce79fa41f68d78ba62d1003cf27bf748ab24413808cf4" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -9562,15 +9380,15 @@ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" -version = "1.0.23" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-xid" @@ -9635,12 +9453,6 @@ dependencies = [ "url", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -9649,11 +9461,11 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.1" +version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ - "getrandom 0.4.1", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -9667,11 +9479,11 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "value-ext" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05ebf9090a4eea10b1962958987cb54ee69f98b45eb918b73cb846bfb8c8c06f" +checksum = "ca945a9c7463ad3085da59b5dc4f8faf4dff6f3e7d835fa7ef08a3b36926512d" dependencies = [ - "derive_more 2.1.1", + "derive_more", "serde", "serde_json", ] @@ -9758,12 +9570,6 @@ dependencies = [ "try-lock", ] -[[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -9772,27 +9578,24 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] [[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +name = "wasite" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen", -] +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -9803,9 +9606,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.72" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -9813,9 +9616,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -9823,48 +9626,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.13.0", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasm-streams" version = "0.5.0" @@ -9878,23 +9659,11 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.15.5", - "indexmap 2.13.0", - "semver", -] - [[package]] name = "wayland-backend" -version = "0.3.15" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", @@ -9905,11 +9674,11 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.14" +version = "0.31.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "rustix", "wayland-backend", "wayland-scanner", @@ -9917,11 +9686,11 @@ dependencies = [ [[package]] name = "wayland-protocols" -version = "0.32.12" +version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-scanner", @@ -9933,7 +9702,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -9942,12 +9711,12 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.10" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml 0.39.4", + "quick-xml 0.41.0", "quote", ] @@ -9962,9 +9731,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.99" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -9980,6 +9749,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + [[package]] name = "webkit2gtk" version = "2.0.2" @@ -10026,9 +9807,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ "rustls-pki-types", ] @@ -10055,7 +9836,7 @@ checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -10064,7 +9845,7 @@ version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.20", "windows 0.61.3", "windows-core 0.61.2", ] @@ -10075,6 +9856,17 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", + "web-sys", +] + [[package]] name = "widestring" version = "1.2.1" @@ -10226,7 +10018,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -10237,7 +10029,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -10599,9 +10391,15 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.14" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -10631,7 +10429,7 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12dafb3c1468d0a3f5440e21e51614b53d1fdc62c9f82cc861c447906d09c69a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "crypto-bigint 0.7.5", "flate2", "iso7816", @@ -10659,91 +10457,9 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.51.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap 2.13.0", - "prettyplease", - "syn 2.0.114", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.114", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.1", - "indexmap 2.13.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.13.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "wl-clipboard-rs" @@ -10755,7 +10471,7 @@ dependencies = [ "log", "os_pipe", "rustix", - "thiserror 2.0.18", + "thiserror 2.0.20", "tree_magic_mini", "wayland-backend", "wayland-client", @@ -10776,30 +10492,29 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wry" -version = "0.54.1" +version = "0.55.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ed1a195b0375491dd15a7066a10251be217ce743cf4bbbbdcf5391d6473bee0" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" dependencies = [ "base64 0.22.1", "block2", "cookie", "crossbeam-channel", "dirs", + "dom_query", "dpi", "dunce", "gdkx11", "gtk", - "html5ever", "http", "javascriptcore-rs", "jni 0.21.1", - "kuchikiki", "libc", "ndk", "objc2", @@ -10814,7 +10529,7 @@ dependencies = [ "sha2 0.10.9", "soup3", "tao-macros", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", "webkit2gtk", "webkit2gtk-sys", @@ -10879,7 +10594,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eee64e8620caa64914d669b1f68f858aaff54e2d0f9ad3b30a613b58a1baa83e" dependencies = [ "curve25519-dalek 5.0.0-rc.1", - "rand_core 0.10.0", + "rand_core 0.10.1", "zeroize", ] @@ -10917,9 +10632,9 @@ dependencies = [ [[package]] name = "yeslogic-fontconfig-sys" -version = "6.0.0" +version = "6.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503a066b4c037c440169d995b869046827dbc71263f6e8f3be6d77d4f3229dbd" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" dependencies = [ "dlib", "once_cell", @@ -10928,9 +10643,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -10939,13 +10654,13 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", "synstructure", ] @@ -10976,10 +10691,10 @@ dependencies = [ "hex", "nix 0.29.0", "ordered-stream", - "rand 0.8.5", + "rand 0.8.8", "serde", "serde_repr", - "sha1 0.10.6", + "sha1 0.10.7", "static_assertions", "tracing", "uds_windows", @@ -10992,9 +10707,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.13.2" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" dependencies = [ "async-broadcast", "async-executor", @@ -11019,10 +10734,10 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 0.7.14", - "zbus_macros 5.13.2", - "zbus_names 4.3.1", - "zvariant 5.9.2", + "winnow 1.0.4", + "zbus_macros 5.19.0", + "zbus_names 4.3.4", + "zvariant 5.15.0", ] [[package]] @@ -11031,26 +10746,26 @@ version = "4.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", "zvariant_utils 2.1.0", ] [[package]] name = "zbus_macros" -version = "5.13.2" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.114", - "zbus_names 4.3.1", - "zvariant 5.9.2", - "zvariant_utils 3.3.0", + "syn 3.0.4", + "zbus_names 4.3.4", + "zvariant 5.15.0", + "zvariant_utils 4.2.0", ] [[package]] @@ -11066,53 +10781,62 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.1" +version = "4.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant 5.15.0", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" dependencies = [ "serde", - "winnow 0.7.14", - "zvariant 5.9.2", ] [[package]] name = "zerocopy" -version = "0.8.39" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.39" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", "synstructure", ] @@ -11133,14 +10857,14 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -11149,9 +10873,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -11160,13 +10884,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 3.0.4", ] [[package]] @@ -11177,30 +10901,30 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" dependencies = [ "arbitrary", "crc32fast", - "indexmap 2.13.0", + "indexmap 2.14.1", "memchr", ] [[package]] name = "zip" -version = "8.2.0" +version = "8.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b680f2a0cd479b4cff6e1233c483fdead418106eae419dc60200ae9850f6d004" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" dependencies = [ - "aes 0.8.4", + "aes 0.9.3", "bzip2", "constant_time_eq", "crc32fast", "deflate64", "flate2", - "getrandom 0.4.1", - "hmac 0.12.1", - "indexmap 2.13.0", + "getrandom 0.4.3", + "hmac 0.13.0", + "indexmap 2.14.1", "lzma-rust2", "memchr", - "pbkdf2 0.12.2", + "pbkdf2 0.13.0", "ppmd-rust", - "sha1 0.10.6", + "sha1 0.11.0", "time", "typed-path", "zeroize", @@ -11210,21 +10934,21 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.3" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zmij" -version = "1.0.20" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zmodem2" version = "0.5.0" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "crc32fast", "hex", "thiserror 1.0.69", @@ -11272,9 +10996,9 @@ dependencies = [ [[package]] name = "zune-core" -version = "0.5.1" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" [[package]] name = "zune-jpeg" @@ -11300,16 +11024,17 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.9.2" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" dependencies = [ "endi", "enumflags2", "serde", - "winnow 0.7.14", - "zvariant_derive 5.9.2", - "zvariant_utils 3.3.0", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive 5.15.0", + "zvariant_utils 4.2.0", ] [[package]] @@ -11318,24 +11043,24 @@ version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", "zvariant_utils 2.1.0", ] [[package]] name = "zvariant_derive" -version = "5.9.2" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.114", - "zvariant_utils 3.3.0", + "syn 3.0.4", + "zvariant_utils 4.2.0", ] [[package]] @@ -11346,18 +11071,18 @@ checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "zvariant_utils" -version = "3.3.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.114", - "winnow 0.7.14", + "syn 3.0.4", + "winnow 1.0.4", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 881f3275..444bf652 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nyaterm" -version = "1.2.5" +version = "1.2.6" description = "A modern remote terminal workspace built with Tauri, React, and Rust." authors = ["Kang"] edition = "2024" @@ -115,6 +115,7 @@ time = { version = "0.3", features = ["formatting", "local-offset", "macros"] } tracing-appender = "0.2" nucleo-matcher = "0.3" dirs = "6.0.0" +whoami = "1" aes-gcm = "0.10" aes = "0.8" cbc = "0.1" @@ -213,6 +214,8 @@ webview2-com = "0.38.2" window-vibrancy = "0.6" windows-sys = { version = "0.61", features = [ "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_LibraryLoader", "Win32_System_Threading", diff --git a/src-tauri/crates/nyaterm-mcp-protocol/src/lib.rs b/src-tauri/crates/nyaterm-mcp-protocol/src/lib.rs index fe34a65b..b5f4b630 100644 --- a/src-tauri/crates/nyaterm-mcp-protocol/src/lib.rs +++ b/src-tauri/crates/nyaterm-mcp-protocol/src/lib.rs @@ -10,6 +10,8 @@ pub const MAX_TEXT_WRITE_BYTES: usize = 1024 * 1024; pub mod capability { pub const ENVIRONMENT: &str = "session.environment"; + pub const CONNECTION_LIST: &str = "connection.list"; + pub const SESSION_OPEN: &str = "session.open"; pub const SESSION_GET: &str = "session.get"; pub const TERMINAL_EXECUTE: &str = "terminal.execute"; pub const TERMINAL_RECENT_OUTPUT: &str = "terminal.recent_output"; @@ -27,6 +29,8 @@ pub mod capability { pub mod tool { pub const GET_ENVIRONMENT: &str = "get_environment"; + pub const CONNECTION_LIST: &str = "connection_list"; + pub const SESSION_OPEN: &str = "session_open"; pub const SESSION_GET: &str = "session_get"; pub const TERMINAL_EXECUTE: &str = "terminal_execute"; pub const TERMINAL_RECENT_OUTPUT: &str = "terminal_recent_output"; @@ -42,6 +46,201 @@ pub mod tool { pub const OUTPUT_READ: &str = "tool_output_read"; } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CapabilityAccess { + Read, + SensitiveRead, + Write, + DestructiveWrite, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct McpToolDefinition { + pub tool: &'static str, + pub capability: &'static str, + pub description: &'static str, + pub access: CapabilityAccess, + pub requires_session: bool, + pub read_only_hint: bool, + pub destructive_hint: bool, + pub open_world_hint: bool, +} + +pub const MCP_TOOL_REGISTRY: &[McpToolDefinition] = &[ + McpToolDefinition { + tool: tool::GET_ENVIRONMENT, + capability: capability::ENVIRONMENT, + description: "Return scoped NyaTerm sessions and the optional active and default sessions.", + access: CapabilityAccess::Read, + requires_session: false, + read_only_hint: true, + destructive_hint: false, + open_world_hint: false, + }, + McpToolDefinition { + tool: tool::CONNECTION_LIST, + capability: capability::CONNECTION_LIST, + description: "List saved terminal connections using safe metadata only.", + access: CapabilityAccess::Read, + requires_session: false, + read_only_hint: true, + destructive_hint: false, + open_world_hint: false, + }, + McpToolDefinition { + tool: tool::SESSION_OPEN, + capability: capability::SESSION_OPEN, + description: "Open a new NyaTerm terminal session from a saved connection.", + access: CapabilityAccess::Write, + requires_session: false, + read_only_hint: false, + destructive_hint: false, + open_world_hint: true, + }, + McpToolDefinition { + tool: tool::SESSION_GET, + capability: capability::SESSION_GET, + description: "Return safe metadata and capability availability for a scoped session.", + access: CapabilityAccess::Read, + requires_session: true, + read_only_hint: true, + destructive_hint: false, + open_world_hint: false, + }, + McpToolDefinition { + tool: tool::TERMINAL_EXECUTE, + capability: capability::TERMINAL_EXECUTE, + description: "Execute a command in an existing scoped NyaTerm terminal session.", + access: CapabilityAccess::Write, + requires_session: true, + read_only_hint: false, + destructive_hint: false, + open_world_hint: true, + }, + McpToolDefinition { + tool: tool::TERMINAL_RECENT_OUTPUT, + capability: capability::TERMINAL_RECENT_OUTPUT, + description: "Read recent ANSI-free terminal output for a scoped session.", + access: CapabilityAccess::SensitiveRead, + requires_session: true, + read_only_hint: true, + destructive_hint: false, + open_world_hint: true, + }, + McpToolDefinition { + tool: tool::SFTP_HOME, + capability: capability::SFTP_HOME, + description: "Return the remote home directory.", + access: CapabilityAccess::SensitiveRead, + requires_session: true, + read_only_hint: true, + destructive_hint: false, + open_world_hint: true, + }, + McpToolDefinition { + tool: tool::SFTP_LIST, + capability: capability::SFTP_LIST, + description: "List a remote directory.", + access: CapabilityAccess::SensitiveRead, + requires_session: true, + read_only_hint: true, + destructive_hint: false, + open_world_hint: true, + }, + McpToolDefinition { + tool: tool::SFTP_STAT, + capability: capability::SFTP_STAT, + description: "Read remote path metadata.", + access: CapabilityAccess::SensitiveRead, + requires_session: true, + read_only_hint: true, + destructive_hint: false, + open_world_hint: true, + }, + McpToolDefinition { + tool: tool::SFTP_READ_TEXT, + capability: capability::SFTP_READ, + description: "Read up to 64 KiB of a remote UTF-8 text file.", + access: CapabilityAccess::SensitiveRead, + requires_session: true, + read_only_hint: true, + destructive_hint: false, + open_world_hint: true, + }, + McpToolDefinition { + tool: tool::SFTP_WRITE_TEXT, + capability: capability::SFTP_WRITE, + description: "Write a remote UTF-8 text file with optional conflict protection.", + access: CapabilityAccess::Write, + requires_session: true, + read_only_hint: false, + destructive_hint: false, + open_world_hint: true, + }, + McpToolDefinition { + tool: tool::SFTP_MKDIR, + capability: capability::SFTP_MKDIR, + description: "Create a remote directory.", + access: CapabilityAccess::Write, + requires_session: true, + read_only_hint: false, + destructive_hint: false, + open_world_hint: true, + }, + McpToolDefinition { + tool: tool::SFTP_RENAME, + capability: capability::SFTP_RENAME, + description: "Rename or move a remote path.", + access: CapabilityAccess::Write, + requires_session: true, + read_only_hint: false, + destructive_hint: false, + open_world_hint: true, + }, + McpToolDefinition { + tool: tool::SFTP_DELETE, + capability: capability::SFTP_DELETE, + description: "Delete a remote path using NyaTerm's existing delete semantics.", + access: CapabilityAccess::DestructiveWrite, + requires_session: true, + read_only_hint: false, + destructive_hint: true, + open_world_hint: true, + }, + McpToolDefinition { + tool: tool::SFTP_CHMOD, + capability: capability::SFTP_CHMOD, + description: "Change remote path permissions.", + access: CapabilityAccess::Write, + requires_session: true, + read_only_hint: false, + destructive_hint: false, + open_world_hint: true, + }, + McpToolDefinition { + tool: tool::OUTPUT_READ, + capability: capability::OUTPUT_READ, + description: "Read another chunk of a large result produced on this MCP connection.", + access: CapabilityAccess::SensitiveRead, + requires_session: false, + read_only_hint: true, + destructive_hint: false, + open_world_hint: false, + }, +]; + +pub fn definition_for_tool(name: &str) -> Option<&'static McpToolDefinition> { + MCP_TOOL_REGISTRY + .iter() + .find(|definition| definition.tool == name) +} + +pub fn definition_for_capability(id: &str) -> Option<&'static McpToolDefinition> { + MCP_TOOL_REGISTRY + .iter() + .find(|definition| definition.capability == id) +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DiscoveryDocument { @@ -112,6 +311,12 @@ pub struct SessionArgs { pub session_id: String, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SessionOpenArgs { + pub connection_id: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct PathArgs { @@ -195,3 +400,43 @@ pub struct OutputReadArgs { #[serde(default)] pub max_bytes: Option, } + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::*; + + #[test] + fn registry_is_unique_and_annotations_match_access() { + let mut tools = HashSet::new(); + let mut capabilities = HashSet::new(); + for definition in MCP_TOOL_REGISTRY { + assert!(tools.insert(definition.tool), "duplicate tool: {}", definition.tool); + assert!( + capabilities.insert(definition.capability), + "duplicate capability: {}", + definition.capability + ); + assert_eq!( + definition.read_only_hint, + matches!( + definition.access, + CapabilityAccess::Read | CapabilityAccess::SensitiveRead + ) + ); + assert_eq!( + definition.destructive_hint, + definition.access == CapabilityAccess::DestructiveWrite + ); + if definition.access == CapabilityAccess::DestructiveWrite { + assert!(definition.destructive_hint); + } + assert_eq!(definition_for_tool(definition.tool), Some(definition)); + assert_eq!( + definition_for_capability(definition.capability), + Some(definition) + ); + } + } +} diff --git a/src-tauri/crates/nyaterm-mcp/Cargo.lock b/src-tauri/crates/nyaterm-mcp/Cargo.lock index 49db9199..54e2c1bb 100644 --- a/src-tauri/crates/nyaterm-mcp/Cargo.lock +++ b/src-tauri/crates/nyaterm-mcp/Cargo.lock @@ -366,7 +366,7 @@ dependencies = [ [[package]] name = "nyaterm-mcp" -version = "1.2.5" +version = "1.2.6" dependencies = [ "dirs", "nyaterm-mcp-protocol", diff --git a/src-tauri/crates/nyaterm-mcp/Cargo.toml b/src-tauri/crates/nyaterm-mcp/Cargo.toml index 5e30af82..009cf523 100644 --- a/src-tauri/crates/nyaterm-mcp/Cargo.toml +++ b/src-tauri/crates/nyaterm-mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nyaterm-mcp" -version = "1.2.5" +version = "1.2.6" edition = "2024" publish = false diff --git a/src-tauri/crates/nyaterm-mcp/src/bridge.rs b/src-tauri/crates/nyaterm-mcp/src/bridge.rs index e7e4eeda..127f5c06 100644 --- a/src-tauri/crates/nyaterm-mcp/src/bridge.rs +++ b/src-tauri/crates/nyaterm-mcp/src/bridge.rs @@ -41,6 +41,7 @@ struct Connection { pub struct BridgeClient { endpoint: BridgeEndpoint, connection: Arc>>, + identity: Arc>>, } impl BridgeClient { @@ -48,12 +49,14 @@ impl BridgeClient { Self { endpoint, connection: Arc::new(Mutex::new(None)), + identity: Arc::new(Mutex::new(None)), } } pub async fn identify(&self, name: String, version: Option) { - let params = - serde_json::to_value(ClientIdentifyParams { name, version }).unwrap_or_default(); + let identity = ClientIdentifyParams { name, version }; + *self.identity.lock().await = Some(identity.clone()); + let params = serde_json::to_value(identity).unwrap_or_default(); let _ = self.rpc("client.identify", params).await; } @@ -71,70 +74,104 @@ impl BridgeClient { }) .map_err(|error| bridge_error("invalid_argument", &error.to_string()))?; let mut guard = self.connection.lock().await; - if guard.is_none() { - *guard = Some(connect(&self.endpoint).await.map_err(io_error)?); - } + self.ensure_connection(&mut guard, true).await?; let connection = guard.as_mut().unwrap(); - let id = connection.next_id; - connection.next_id += 1; - write_request( - connection, - RpcRequest { - id, - method: "capability.execute".into(), - params, - }, - ) - .await - .map_err(io_error)?; - let response = tokio::select! { + let result = tokio::select! { _ = cancellation.cancelled() => { let endpoint = self.endpoint.clone(); tokio::spawn(async move { let _ = cancel_request(&endpoint, &request_id).await; }); *guard = None; return Err(bridge_error("cancelled", "The MCP tool call was cancelled.")); } - response = read_response(connection) => response.map_err(io_error)?, + result = connection_rpc(connection, "capability.execute", params) => result, }; - if response.id != id { - *guard = None; - return Err(bridge_error( - "bridge_disconnected", - "MCP bridge response ID mismatch.", - )); - } - match (response.result, response.error) { - (Some(value), None) => Ok(value), - (_, Some(error)) => Err(error), - _ => Err(bridge_error( - "bridge_disconnected", - "MCP bridge returned an empty response.", - )), - } + finish_rpc(&mut guard, result) } async fn rpc(&self, method: &str, params: Value) -> Result { let mut guard = self.connection.lock().await; - if guard.is_none() { - *guard = Some(connect(&self.endpoint).await.map_err(io_error)?); - } + self.ensure_connection(&mut guard, method != "client.identify") + .await?; let connection = guard.as_mut().unwrap(); - let id = connection.next_id; - connection.next_id += 1; - write_request( - connection, - RpcRequest { - id, - method: method.into(), - params, - }, - ) + let result = connection_rpc(connection, method, params).await; + finish_rpc(&mut guard, result) + } + + async fn ensure_connection( + &self, + guard: &mut Option, + replay_identity: bool, + ) -> Result<(), RpcError> { + if guard.is_some() { + return Ok(()); + } + let mut connection = connect(&self.endpoint).await.map_err(io_error)?; + if replay_identity && let Some(identity) = self.identity.lock().await.clone() { + let params = serde_json::to_value(identity) + .map_err(|error| bridge_error("invalid_argument", &error.to_string()))?; + match connection_rpc(&mut connection, "client.identify", params).await { + Ok(_) => {} + Err(ConnectionRpcError::Remote(error)) => return Err(error), + Err(ConnectionRpcError::Disconnected(error)) => return Err(error), + } + } + *guard = Some(connection); + Ok(()) + } +} + +enum ConnectionRpcError { + Remote(RpcError), + Disconnected(RpcError), +} + +fn finish_rpc( + guard: &mut Option, + result: Result, +) -> Result { + match result { + Ok(value) => Ok(value), + Err(ConnectionRpcError::Remote(error)) => Err(error), + Err(ConnectionRpcError::Disconnected(error)) => { + *guard = None; + Err(error) + } + } +} + +async fn connection_rpc( + connection: &mut Connection, + method: &str, + params: Value, +) -> Result { + let id = connection.next_id; + connection.next_id += 1; + write_request( + connection, + RpcRequest { + id, + method: method.into(), + params, + }, + ) + .await + .map_err(|error| ConnectionRpcError::Disconnected(io_error(error)))?; + let response = read_response(connection) .await - .map_err(io_error)?; - let response = read_response(connection).await.map_err(io_error)?; - response - .error - .map_or_else(|| Ok(response.result.unwrap_or(Value::Null)), Err) + .map_err(|error| ConnectionRpcError::Disconnected(io_error(error)))?; + if response.id != id { + return Err(ConnectionRpcError::Disconnected(bridge_error( + "bridge_disconnected", + "MCP bridge response ID mismatch.", + ))); + } + match (response.result, response.error) { + (Some(value), None) => Ok(value), + (_, Some(error)) => Err(ConnectionRpcError::Remote(error)), + _ => Err(ConnectionRpcError::Disconnected(bridge_error( + "bridge_disconnected", + "MCP bridge returned an empty response.", + ))), } } @@ -144,43 +181,36 @@ async fn connect(endpoint: &BridgeEndpoint) -> std::io::Result { let mut connection = Connection { reader: BufReader::new(read), writer: write, - next_id: 2, + next_id: 1, }; let params = serde_json::to_value(AuthParams { token: endpoint.token.clone(), generation: endpoint.generation.clone(), }) .map_err(std::io::Error::other)?; - write_request( - &mut connection, - RpcRequest { - id: 1, - method: "auth".into(), - params, - }, - ) - .await?; - if read_response(&mut connection).await?.error.is_some() { - return Err(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - "NyaTerm MCP authentication failed", - )); - } + connection_rpc(&mut connection, "auth", params) + .await + .map_err(|error| match error { + ConnectionRpcError::Remote(error) | ConnectionRpcError::Disconnected(error) => { + std::io::Error::new(std::io::ErrorKind::PermissionDenied, error.message) + } + })?; Ok(connection) } async fn cancel_request(endpoint: &BridgeEndpoint, request_id: &str) -> std::io::Result<()> { let mut connection = connect(endpoint).await?; - write_request( + connection_rpc( &mut connection, - RpcRequest { - id: 2, - method: "request.cancel".into(), - params: json!({ "requestId": request_id }), - }, + "request.cancel", + json!({ "requestId": request_id }), ) - .await?; - let _ = read_response(&mut connection).await?; + .await + .map_err(|error| match error { + ConnectionRpcError::Remote(error) | ConnectionRpcError::Disconnected(error) => { + std::io::Error::other(error.message) + } + })?; Ok(()) } @@ -268,3 +298,211 @@ fn bridge_error(code: &str, message: &str) -> RpcError { message: message.into(), } } + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use tokio::net::TcpListener; + + use super::*; + + async fn request( + lines: &mut tokio::io::Lines>, + ) -> RpcRequest { + serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap() + } + + async fn respond(writer: &mut tokio::net::tcp::OwnedWriteHalf, id: u64, result: Value) { + let mut bytes = serde_json::to_vec(&RpcResponse { + id, + result: Some(result), + error: None, + }) + .unwrap(); + bytes.push(b'\n'); + writer.write_all(&bytes).await.unwrap(); + } + + async fn authenticate( + stream: TcpStream, + auth_count: &AtomicUsize, + ) -> ( + tokio::io::Lines>, + tokio::net::tcp::OwnedWriteHalf, + ) { + let (reader, mut writer) = stream.into_split(); + let mut lines = BufReader::new(reader).lines(); + let auth = request(&mut lines).await; + assert_eq!(auth.method, "auth"); + assert_eq!(auth.params["token"], "test-token"); + auth_count.fetch_add(1, Ordering::SeqCst); + respond(&mut writer, auth.id, json!({ "authenticated": true })).await; + (lines, writer) + } + + #[tokio::test] + async fn disconnect_invalidates_and_next_call_reauthenticates_and_identifies() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let auth_count = Arc::new(AtomicUsize::new(0)); + let server_count = auth_count.clone(); + let server = tokio::spawn(async move { + let (first, _) = listener.accept().await.unwrap(); + let (mut lines, mut writer) = authenticate(first, &server_count).await; + let identify = request(&mut lines).await; + assert_eq!(identify.method, "client.identify"); + respond(&mut writer, identify.id, json!({ "identified": true })).await; + let call = request(&mut lines).await; + assert_eq!(call.method, "capability.execute"); + respond(&mut writer, call.id, json!({ "value": "first" })).await; + drop(writer); + + let (second, _) = listener.accept().await.unwrap(); + let (mut lines, mut writer) = authenticate(second, &server_count).await; + let identify = request(&mut lines).await; + assert_eq!(identify.method, "client.identify"); + respond(&mut writer, identify.id, json!({ "identified": true })).await; + let call = request(&mut lines).await; + respond(&mut writer, call.id, json!({ "value": "recovered" })).await; + }); + + let client = BridgeClient::new(BridgeEndpoint::for_test(port)); + client + .identify("bridge-test".into(), Some("1.0".into())) + .await; + let first = client + .call("get_environment", json!({}), CancellationToken::new()) + .await + .unwrap(); + assert_eq!(first["value"], "first"); + + let disconnected = client + .call("get_environment", json!({}), CancellationToken::new()) + .await + .unwrap_err(); + assert_eq!(disconnected.code, "bridge_disconnected"); + + let recovered = client + .call("get_environment", json!({}), CancellationToken::new()) + .await + .unwrap(); + assert_eq!(recovered["value"], "recovered"); + server.await.unwrap(); + assert_eq!(auth_count.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn response_id_mismatch_invalidates_the_connection() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let auth_count = Arc::new(AtomicUsize::new(0)); + let server_count = auth_count.clone(); + let server = tokio::spawn(async move { + let (first, _) = listener.accept().await.unwrap(); + let (mut lines, mut writer) = authenticate(first, &server_count).await; + let call = request(&mut lines).await; + respond(&mut writer, call.id + 1, json!({ "wrong": true })).await; + drop(writer); + + let (second, _) = listener.accept().await.unwrap(); + let (mut lines, mut writer) = authenticate(second, &server_count).await; + let call = request(&mut lines).await; + respond(&mut writer, call.id, json!({ "recovered": true })).await; + }); + + let client = BridgeClient::new(BridgeEndpoint::for_test(port)); + let mismatch = client + .call("get_environment", json!({}), CancellationToken::new()) + .await + .unwrap_err(); + assert_eq!(mismatch.code, "bridge_disconnected"); + let recovered = client + .call("get_environment", json!({}), CancellationToken::new()) + .await + .unwrap(); + assert_eq!(recovered["recovered"], true); + server.await.unwrap(); + assert_eq!(auth_count.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn invalid_response_invalidates_the_connection() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let auth_count = Arc::new(AtomicUsize::new(0)); + let server_count = auth_count.clone(); + let server = tokio::spawn(async move { + let (first, _) = listener.accept().await.unwrap(); + let (mut lines, mut writer) = authenticate(first, &server_count).await; + let _ = request(&mut lines).await; + writer.write_all(b"not-json\n").await.unwrap(); + drop(writer); + + let (second, _) = listener.accept().await.unwrap(); + let (mut lines, mut writer) = authenticate(second, &server_count).await; + let call = request(&mut lines).await; + respond(&mut writer, call.id, json!({ "recovered": true })).await; + }); + + let client = BridgeClient::new(BridgeEndpoint::for_test(port)); + let invalid = client + .call("get_environment", json!({}), CancellationToken::new()) + .await + .unwrap_err(); + assert_eq!(invalid.code, "bridge_disconnected"); + assert!( + client + .call("get_environment", json!({}), CancellationToken::new()) + .await + .unwrap()["recovered"] + .as_bool() + .unwrap() + ); + server.await.unwrap(); + assert_eq!(auth_count.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn empty_response_invalidates_the_connection() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let auth_count = Arc::new(AtomicUsize::new(0)); + let server_count = auth_count.clone(); + let server = tokio::spawn(async move { + let (first, _) = listener.accept().await.unwrap(); + let (mut lines, mut writer) = authenticate(first, &server_count).await; + let call = request(&mut lines).await; + let mut bytes = serde_json::to_vec(&RpcResponse { + id: call.id, + result: None, + error: None, + }) + .unwrap(); + bytes.push(b'\n'); + writer.write_all(&bytes).await.unwrap(); + drop(writer); + + let (second, _) = listener.accept().await.unwrap(); + let (mut lines, mut writer) = authenticate(second, &server_count).await; + let call = request(&mut lines).await; + respond(&mut writer, call.id, json!({ "recovered": true })).await; + }); + + let client = BridgeClient::new(BridgeEndpoint::for_test(port)); + let empty = client + .call("get_environment", json!({}), CancellationToken::new()) + .await + .unwrap_err(); + assert_eq!(empty.code, "bridge_disconnected"); + assert_eq!( + client + .call("get_environment", json!({}), CancellationToken::new()) + .await + .unwrap()["recovered"], + true + ); + server.await.unwrap(); + assert_eq!(auth_count.load(Ordering::SeqCst), 2); + } +} diff --git a/src-tauri/crates/nyaterm-mcp/src/main.rs b/src-tauri/crates/nyaterm-mcp/src/main.rs index ab2f4dd6..ac6a9420 100644 --- a/src-tauri/crates/nyaterm-mcp/src/main.rs +++ b/src-tauri/crates/nyaterm-mcp/src/main.rs @@ -4,9 +4,9 @@ use std::sync::Arc; use bridge::{BridgeClient, BridgeEndpoint, endpoint_from_environment_or_discovery}; use nyaterm_mcp_protocol::{ - EmptyArgs, OutputReadArgs, PathArgs, SessionArgs, SftpChmodArgs, SftpMkdirArgs, - SftpReadTextArgs, SftpRenameArgs, SftpWriteTextArgs, TerminalExecuteArgs, - TerminalRecentOutputArgs, tool, + EmptyArgs, MCP_TOOL_REGISTRY, McpToolDefinition, OutputReadArgs, PathArgs, SessionArgs, + SftpChmodArgs, SftpMkdirArgs, SftpReadTextArgs, SftpRenameArgs, SftpWriteTextArgs, + SessionOpenArgs, TerminalExecuteArgs, TerminalRecentOutputArgs, tool, }; use rmcp::model::{ CallToolRequestParams, CallToolResponse, CallToolResult, Implementation, ListToolsResult, @@ -39,7 +39,7 @@ impl ServerHandler for NyaTermMcp { "nyaterm-mcp", env!("CARGO_PKG_VERSION"), )) - .with_instructions("Operate sessions already opened in NyaTerm. NyaTerm enforces session scope and approvals.") + .with_instructions("Discover saved terminal connections, open sessions in NyaTerm, and operate scoped sessions. NyaTerm enforces session scope and approvals.") } async fn call_tool( @@ -89,94 +89,39 @@ impl ServerHandler for NyaTermMcp { } fn build_tools() -> Vec { - vec![ - tool_def::( - tool::GET_ENVIRONMENT, - "Return scoped NyaTerm sessions and the optional default session.", - true, - false, - ), - tool_def::( - tool::SESSION_GET, - "Return safe metadata and capability availability for a scoped session.", - true, - false, - ), - tool_def::( - tool::TERMINAL_EXECUTE, - "Execute a command in an existing scoped NyaTerm terminal session.", - false, - false, - ), - tool_def::( - tool::TERMINAL_RECENT_OUTPUT, - "Read recent ANSI-free terminal output for a scoped session.", - true, - false, - ), - tool_def::( - tool::SFTP_HOME, - "Return the remote home directory.", - true, - false, - ), - tool_def::(tool::SFTP_LIST, "List a remote directory.", true, false), - tool_def::(tool::SFTP_STAT, "Read remote path metadata.", true, false), - tool_def::( - tool::SFTP_READ_TEXT, - "Read up to 64 KiB of a remote UTF-8 text file.", - true, - false, - ), - tool_def::( - tool::SFTP_WRITE_TEXT, - "Write a remote UTF-8 text file with optional conflict protection.", - false, - false, - ), - tool_def::(tool::SFTP_MKDIR, "Create a remote directory.", false, false), - tool_def::( - tool::SFTP_RENAME, - "Rename or move a remote path.", - false, - false, - ), - tool_def::( - tool::SFTP_DELETE, - "Delete a remote path using NyaTerm's existing delete semantics.", - false, - true, - ), - tool_def::( - tool::SFTP_CHMOD, - "Change remote path permissions.", - false, - false, - ), - tool_def::( - tool::OUTPUT_READ, - "Read another chunk of a large result produced on this MCP connection.", - true, - false, - ), - ] + MCP_TOOL_REGISTRY + .iter() + .map(|definition| match definition.tool { + tool::GET_ENVIRONMENT => tool_def::(definition), + tool::CONNECTION_LIST => tool_def::(definition), + tool::SESSION_OPEN => tool_def::(definition), + tool::SESSION_GET | tool::SFTP_HOME => tool_def::(definition), + tool::TERMINAL_EXECUTE => tool_def::(definition), + tool::TERMINAL_RECENT_OUTPUT => tool_def::(definition), + tool::SFTP_LIST | tool::SFTP_STAT | tool::SFTP_DELETE => { + tool_def::(definition) + } + tool::SFTP_READ_TEXT => tool_def::(definition), + tool::SFTP_WRITE_TEXT => tool_def::(definition), + tool::SFTP_MKDIR => tool_def::(definition), + tool::SFTP_RENAME => tool_def::(definition), + tool::SFTP_CHMOD => tool_def::(definition), + tool::OUTPUT_READ => tool_def::(definition), + _ => unreachable!("registry contains an unknown MCP tool"), + }) + .collect() } -fn tool_def( - name: &'static str, - description: &'static str, - read_only: bool, - destructive: bool, -) -> Tool { +fn tool_def(definition: &McpToolDefinition) -> Tool { let schema = serde_json::to_value(schemars::schema_for!(T)) .unwrap_or_else(|_| json!({ "type": "object" })); let object = schema.as_object().cloned().unwrap_or_else(Map::new); - let mut item = Tool::new(name, description, object); + let mut item = Tool::new(definition.tool, definition.description, object); item.annotations = Some( ToolAnnotations::new() - .read_only(read_only) - .destructive(destructive) - .open_world(false), + .read_only(definition.read_only_hint) + .destructive(definition.destructive_hint) + .open_world(definition.open_world_hint), ); item } @@ -193,7 +138,7 @@ async fn main() -> Result<(), Box> { #[cfg(test)] mod tests { - use nyaterm_mcp_protocol::{RpcRequest, RpcResponse}; + use nyaterm_mcp_protocol::{MCP_TOOL_REGISTRY, RpcRequest, RpcResponse}; use tokio::{ io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines, ReadHalf, WriteHalf}, net::TcpListener, @@ -201,6 +146,54 @@ mod tests { use super::*; + #[test] + fn listed_tool_annotations_come_from_the_shared_registry() { + let tools = build_tools(); + assert_eq!(tools.len(), MCP_TOOL_REGISTRY.len()); + for definition in MCP_TOOL_REGISTRY { + let tool = tools + .iter() + .find(|tool| tool.name == definition.tool) + .unwrap(); + let value = serde_json::to_value(tool).unwrap(); + assert_eq!( + value["annotations"]["readOnlyHint"], + definition.read_only_hint + ); + assert_eq!( + value["annotations"]["destructiveHint"], + definition.destructive_hint + ); + assert_eq!( + value["annotations"]["openWorldHint"], + definition.open_world_hint + ); + } + + let connection_list = tools + .iter() + .find(|item| item.name == tool::CONNECTION_LIST) + .expect("connection_list tool"); + let connection_list = serde_json::to_value(connection_list).unwrap(); + assert_eq!(connection_list["inputSchema"]["type"], "object"); + assert_eq!(connection_list["annotations"]["readOnlyHint"], true); + + let session_open = tools + .iter() + .find(|item| item.name == tool::SESSION_OPEN) + .expect("session_open tool"); + let session_open = serde_json::to_value(session_open).unwrap(); + assert_eq!( + session_open["inputSchema"]["required"], + json!(["connectionId"]) + ); + assert_eq!( + session_open["inputSchema"]["properties"]["connectionId"]["type"], + "string" + ); + assert_eq!(session_open["annotations"]["readOnlyHint"], false); + } + async fn send_client_message(writer: &mut WriteHalf, raw: &str) { let value = serde_json::from_str::(raw).expect("valid MCP client message"); writer @@ -247,10 +240,33 @@ mod tests { assert_eq!(request.params["name"], "integration-test"); json!({ "identified": true }) } - "capability.execute" => { - assert_eq!(request.params["tool"], tool::GET_ENVIRONMENT); - json!({ "defaultSessionId": "session-1", "sessions": [] }) - } + "capability.execute" => match request.params["tool"].as_str().unwrap() { + tool::GET_ENVIRONMENT => { + json!({ "defaultSessionId": "session-1", "sessions": [] }) + } + tool::CONNECTION_LIST => json!({ + "connections": [{ + "id": "connection-1", + "name": "Local shell", + "type": "local_terminal", + "groupPath": [] + }] + }), + tool::SESSION_OPEN => { + assert_eq!( + request.params["arguments"]["connectionId"], + "connection-1" + ); + json!({ + "sessionId": "session-2", + "connectionId": "connection-1", + "name": "Local shell", + "type": "local", + "connected": true + }) + } + other => panic!("unexpected tool: {other}"), + }, other => panic!("unexpected bridge method: {other}"), }; let response = RpcResponse { @@ -303,7 +319,7 @@ mod tests { ) .await; let listed = receive_response(&mut client_lines, 2).await; - assert_eq!(listed["result"]["tools"].as_array().unwrap().len(), 14); + assert_eq!(listed["result"]["tools"].as_array().unwrap().len(), 16); send_client_message( &mut client_writer, @@ -321,6 +337,47 @@ mod tests { "session-1" ); + send_client_message( + &mut client_writer, + r#"{ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { "name": "connection_list", "arguments": {} } + }"#, + ) + .await; + let connections = receive_response(&mut client_lines, 4).await; + assert_eq!( + connections["result"]["structuredContent"]["connections"][0]["id"], + "connection-1" + ); + + send_client_message( + &mut client_writer, + r#"{ + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "session_open", + "arguments": { "connectionId": "connection-1" } + } + }"#, + ) + .await; + let opened = receive_response(&mut client_lines, 5).await; + assert_eq!( + opened["result"]["structuredContent"], + json!({ + "sessionId": "session-2", + "connectionId": "connection-1", + "name": "Local shell", + "type": "local", + "connected": true + }) + ); + drop(client_writer); drop(client_lines); server_task.abort(); diff --git a/src-tauri/src/cmd/local_fs.rs b/src-tauri/src/cmd/local_fs.rs index de519fe7..ca3017b5 100644 --- a/src-tauri/src/cmd/local_fs.rs +++ b/src-tauri/src/cmd/local_fs.rs @@ -427,6 +427,12 @@ async fn file_entry_from_path(path: &Path, name: String) -> AppResult async fn file_properties_from_path(path: &Path) -> AppResult { let symlink_metadata = tokio::fs::symlink_metadata(path).await?; + let is_symlink = symlink_metadata.file_type().is_symlink(); + let symlink_target = if is_symlink { + Some(path_to_string(tokio::fs::read_link(path).await?)) + } else { + None + }; let metadata = tokio::fs::metadata(path) .await .unwrap_or_else(|_| symlink_metadata.clone()); @@ -438,7 +444,8 @@ async fn file_properties_from_path(path: &Path) -> AppResult { Ok(FileProperties { name, is_dir: metadata.is_dir(), - is_symlink: symlink_metadata.file_type().is_symlink(), + is_symlink, + symlink_target, size: if metadata.is_dir() { 0 } else { metadata.len() }, permissions: permissions_string(&metadata, metadata.is_dir()), owner: owner_string(&metadata), @@ -782,6 +789,40 @@ mod tests { cleanup(&root).await; } + #[tokio::test] + async fn regular_file_properties_have_no_symlink_target() { + let root = temp_test_dir("regular-properties"); + tokio::fs::create_dir_all(&root).await.unwrap(); + let file = root.join("file.txt"); + tokio::fs::write(&file, b"hello").await.unwrap(); + + let properties = file_properties_from_path(&file).await.unwrap(); + assert!(!properties.is_symlink); + assert_eq!(properties.symlink_target, None); + + cleanup(&root).await; + } + + #[cfg(unix)] + #[tokio::test] + async fn dangling_symlink_properties_preserve_relative_target() { + use std::os::unix::fs::symlink; + + let root = temp_test_dir("dangling-symlink-properties"); + tokio::fs::create_dir_all(&root).await.unwrap(); + let link = root.join("current"); + symlink("../missing-release", &link).unwrap(); + + let properties = file_properties_from_path(&link).await.unwrap(); + assert!(properties.is_symlink); + assert_eq!( + properties.symlink_target.as_deref(), + Some("../missing-release") + ); + + cleanup(&root).await; + } + #[tokio::test] async fn ensure_local_session_rejects_non_local_sessions() { let manager = SessionManager::new(); diff --git a/src-tauri/src/cmd/mcp.rs b/src-tauri/src/cmd/mcp.rs index 8f2caeb5..841f7574 100644 --- a/src-tauri/src/cmd/mcp.rs +++ b/src-tauri/src/cmd/mcp.rs @@ -39,17 +39,27 @@ pub async fn set_external_mcp_enabled( .inner() .configure_external(settings, &owner_window_label) .await?; - crate::storage::update_settings_doc( + if let Err(error) = crate::storage::update_settings_doc( crate::storage::SettingsDocKey::AppSettings, |stored: &mut crate::config::AppSettings| { stored.ai.external_mcp.enabled = true; Ok(()) }, - )?; + ) { + let _ = manager.disable_external(false).await; + return Err(error); + } let _ = app.emit("settings-changed", ()); Ok(status) } else { - manager.disable_external(true).await?; + if let Err(error) = manager.disable_external(true).await { + settings.enabled = true; + let _ = manager + .inner() + .configure_external(settings, &owner_window_label) + .await; + return Err(error); + } Ok(manager.status().await) } } @@ -69,6 +79,38 @@ pub async fn respond_external_mcp_approval( manager.respond_approval(&request_id, decision).await } +#[tauri::command] +pub async fn report_mcp_active_session( + window: tauri::WebviewWindow, + manager: tauri::State<'_, Arc>, + session_id: Option, +) -> AppResult<()> { + if !crate::window_state::is_main_window_label(window.label()) { + return Err(AppError::Config( + "Only a NyaTerm main window can report its active MCP session.".into(), + )); + } + manager.set_active_session(window.label(), session_id).await +} + +#[tauri::command] +pub async fn respond_mcp_session_open( + window: tauri::WebviewWindow, + manager: tauri::State<'_, Arc>, + request_id: String, + session_id: Option, + error: Option, +) -> AppResult<()> { + if !crate::window_state::is_main_window_label(window.label()) { + return Err(AppError::Config( + "Only a NyaTerm main window can complete an MCP session-open request.".into(), + )); + } + manager + .respond_session_open(window.label(), &request_id, session_id, error) + .await +} + #[tauri::command] pub fn get_external_mcp_client_configs( manager: tauri::State<'_, Arc>, diff --git a/src-tauri/src/cmd/mod.rs b/src-tauri/src/cmd/mod.rs index 1bd30229..0fe692fe 100644 --- a/src-tauri/src/cmd/mod.rs +++ b/src-tauri/src/cmd/mod.rs @@ -22,6 +22,7 @@ pub mod rdp; pub mod session; pub mod settings; pub mod sftp; +pub mod ssh_config; pub mod stats; pub mod translate; pub mod tunnel; diff --git a/src-tauri/src/cmd/settings.rs b/src-tauri/src/cmd/settings.rs index 10f549c6..7180dccd 100644 --- a/src-tauri/src/cmd/settings.rs +++ b/src-tauri/src/cmd/settings.rs @@ -74,11 +74,6 @@ pub async fn save_app_settings( allow_master_password_change: Option, owner_window_label: Option, ) -> AppResult<()> { - if !(1..=120).contains(&settings.ai.external_mcp.idle_timeout_minutes) { - return Err(AppError::Config( - "External MCP idle timeout must be between 1 and 120 minutes.".into(), - )); - } let previous_mcp = config::load_app_settings(&app)?.ai.external_mcp; let next_mcp = settings.ai.external_mcp.clone(); let external_owner = if previous_mcp != next_mcp && next_mcp.enabled { diff --git a/src-tauri/src/cmd/sftp.rs b/src-tauri/src/cmd/sftp.rs index c88319b1..24c55d67 100644 --- a/src-tauri/src/cmd/sftp.rs +++ b/src-tauri/src/cmd/sftp.rs @@ -229,6 +229,24 @@ pub async fn create_remote_symlink( sftp::create_remote_symlink(state.inner().clone(), &session_id, &link_path, &target_path).await } +#[tauri::command] +pub async fn update_remote_symlink_target( + state: tauri::State<'_, Arc>, + session_id: String, + path: String, + raw_path_token: Option, + target_path: String, +) -> AppResult<()> { + sftp::update_remote_symlink_target( + state.inner().clone(), + &session_id, + &path, + raw_path_token.as_deref(), + &target_path, + ) + .await +} + #[tauri::command] pub async fn chmod_remote_file( state: tauri::State<'_, Arc>, diff --git a/src-tauri/src/cmd/ssh_config.rs b/src-tauri/src/cmd/ssh_config.rs new file mode 100644 index 00000000..ebf53904 --- /dev/null +++ b/src-tauri/src/cmd/ssh_config.rs @@ -0,0 +1,33 @@ +use crate::core::ssh_config::{SshConfig, SshConfigEntry}; +use crate::error::AppResult; + +/// Lists all concrete host entries from `~/.ssh/config` with resolved ProxyJump chains. +#[tauri::command] +pub fn list_ssh_config_hosts() -> AppResult> { + let config = SshConfig::load_default()?; + config.to_entries() +} + +/// Returns the raw parsed `Host` blocks from `~/.ssh/config`. +#[tauri::command] +pub fn get_ssh_config() -> AppResult { + SshConfig::load_default() +} + +/// Resolves a single host alias into a fully-resolved entry with hops. +#[tauri::command] +pub fn resolve_ssh_host(alias: String) -> AppResult { + let config = SshConfig::load_default()?; + config.resolve(&alias) +} + +/// Imports all concrete SSH config hosts as saved connections. +/// Returns the number of connections imported (skips duplicates by name). +#[tauri::command] +pub fn import_ssh_config_hosts(app: tauri::AppHandle) -> AppResult { + let count = crate::core::ssh_config::import_ssh_config_connections(&app)?; + tauri::async_runtime::spawn(async move { + crate::core::cloud_sync::notify_config_changed(&app).await; + }); + Ok(count) +} diff --git a/src-tauri/src/config/connection.rs b/src-tauri/src/config/connection.rs index 58e8c2b9..e178f0bd 100644 --- a/src-tauri/src/config/connection.rs +++ b/src-tauri/src/config/connection.rs @@ -435,6 +435,12 @@ pub struct SftpSettings { pub shell_detection_timeout_ms: u64, #[serde(default, skip_serializing_if = "String::is_empty")] pub filename_encoding: String, + #[serde( + default, + deserialize_with = "deserialize_sftp_pipeline_depth", + skip_serializing_if = "Option::is_none" + )] + pub pipeline_depth: Option, } impl Default for SftpSettings { @@ -444,6 +450,7 @@ impl Default for SftpSettings { cwd_follow_mode: SftpCwdFollowMode::ShellIntegration, shell_detection_timeout_ms: default_sftp_shell_detection_timeout_ms(), filename_encoding: String::new(), + pipeline_depth: None, } } } @@ -497,6 +504,34 @@ pub fn resolve_ssh_terminal_type( pub const MIN_SFTP_SHELL_DETECTION_TIMEOUT_MS: u64 = 100; pub const MAX_SFTP_SHELL_DETECTION_TIMEOUT_MS: u64 = 60_000; +pub const MIN_SFTP_PIPELINE_DEPTH: u32 = 4; +pub const MAX_SFTP_PIPELINE_DEPTH: u32 = 64; + +fn deserialize_sftp_pipeline_depth<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum PipelineDepthValue { + Signed(i64), + Unsigned(u64), + Invalid(serde::de::IgnoredAny), + } + + let value = Option::::deserialize(deserializer)?; + Ok(match value { + Some(PipelineDepthValue::Signed(value)) => Some(value.clamp( + i64::from(MIN_SFTP_PIPELINE_DEPTH), + i64::from(MAX_SFTP_PIPELINE_DEPTH), + ) as u32), + Some(PipelineDepthValue::Unsigned(value)) => Some(value.clamp( + u64::from(MIN_SFTP_PIPELINE_DEPTH), + u64::from(MAX_SFTP_PIPELINE_DEPTH), + ) as u32), + Some(PipelineDepthValue::Invalid(_)) | None => None, + }) +} pub fn default_sftp_shell_detection_timeout_ms() -> u64 { 3000 @@ -1377,8 +1412,9 @@ pub fn save_config(app: &AppHandle, config: &AppConfig) -> AppResult<()> { mod tests { use super::{ AssetAcceleratorType, AssetDeviceType, AssetDiskKind, AssetDiskPurpose, ConnectionType, - MAX_SSH_AGENT_ENVIRONMENT_VARIABLE_LEN, MAX_SSH_AGENT_FORWARDING_ENDPOINTS, - MAX_SSH_AGENT_FORWARDING_IDENTITIES, MAX_SSH_AGENT_UNIX_SOCKET_PATH_LEN, SavedConnection, + MAX_SFTP_PIPELINE_DEPTH, MAX_SSH_AGENT_ENVIRONMENT_VARIABLE_LEN, + MAX_SSH_AGENT_FORWARDING_ENDPOINTS, MAX_SSH_AGENT_FORWARDING_IDENTITIES, + MAX_SSH_AGENT_UNIX_SOCKET_PATH_LEN, MIN_SFTP_PIPELINE_DEPTH, SavedConnection, SftpCwdFollowMode, SftpSettings, SshAgentEndpoint, SshAgentForwardingConfig, SshAgentForwardingPolicy, SshAgentForwardingSources, SshAlgorithmMode, SshProfile, SshTerminalType, effective_cwd_follow_mode, effective_cwd_follow_mode_for_profile, @@ -1934,6 +1970,7 @@ mod tests { SftpCwdFollowMode::ShellIntegration ); assert_eq!(connection.sftp.shell_detection_timeout_ms, 3000); + assert_eq!(connection.sftp.pipeline_depth, None); } #[test] @@ -1956,6 +1993,42 @@ mod tests { assert_eq!(connection.sftp.shell_detection_timeout_ms, 5000); } + #[test] + fn sftp_pipeline_depth_roundtrips_and_automatic_is_omitted() { + let automatic = serde_json::to_value(SftpSettings::default()).expect("automatic settings"); + assert!(automatic.get("pipeline_depth").is_none()); + + let settings = SftpSettings { + pipeline_depth: Some(32), + ..SftpSettings::default() + }; + let encoded = serde_json::to_value(&settings).expect("manual settings"); + assert_eq!(encoded["pipeline_depth"], 32); + let decoded: SftpSettings = serde_json::from_value(encoded).expect("roundtrip settings"); + assert_eq!(decoded.pipeline_depth, Some(32)); + } + + #[test] + fn sftp_pipeline_depth_normalizes_invalid_imported_values() { + let below_minimum: SftpSettings = + serde_json::from_value(serde_json::json!({ "pipeline_depth": 0 })) + .expect("below-minimum settings"); + let above_maximum: SftpSettings = + serde_json::from_value(serde_json::json!({ "pipeline_depth": 999 })) + .expect("above-maximum settings"); + let negative: SftpSettings = + serde_json::from_value(serde_json::json!({ "pipeline_depth": -10 })) + .expect("negative settings"); + let invalid_type: SftpSettings = + serde_json::from_value(serde_json::json!({ "pipeline_depth": "fast" })) + .expect("invalid-type settings"); + + assert_eq!(below_minimum.pipeline_depth, Some(MIN_SFTP_PIPELINE_DEPTH)); + assert_eq!(above_maximum.pipeline_depth, Some(MAX_SFTP_PIPELINE_DEPTH)); + assert_eq!(negative.pipeline_depth, Some(MIN_SFTP_PIPELINE_DEPTH)); + assert_eq!(invalid_type.pipeline_depth, None); + } + #[test] fn effective_cwd_follow_is_off_when_sftp_disabled_without_mutating_setting() { let settings = SftpSettings { diff --git a/src-tauri/src/config/mod.rs b/src-tauri/src/config/mod.rs index 9127ece4..66c0ff93 100644 --- a/src-tauri/src/config/mod.rs +++ b/src-tauri/src/config/mod.rs @@ -82,13 +82,12 @@ pub use settings::{ AiModelSource, AiPermissionMode, AiProviderCredential, AiProviderKind, AiProviderProfile, AiReasoningEffort, AiSettings, AppSettings, AppearanceSettings, ClaudeCodeIntegrationSettings, CodexIntegrationSettings, CodexThreadMode, DiagnosticsLogLevel, DiagnosticsSettings, - ExternalMcpServerMode, ExternalMcpSessionScope, ExternalMcpSettings, GeneralSettings, - InteractionSettings, KeywordHighlightRule, ProxySettings, RecordingSettings, RiskLevel, - SearchEngine, SearchSettings, SecuritySettings, TerminalColorsConfig, TerminalSettings, - ThemeColorsConfig, ThemeConfig, TransferSettings, TranslationSettings, - ai_model_id_for_credential, ai_model_id_for_provider, decrypt_ai_settings, encrypt_ai_settings, - load_app_settings, mask_ai_settings, merge_masked_ai_settings, normalize_ai_settings, - save_app_settings, + ExternalMcpSessionScope, ExternalMcpSettings, GeneralSettings, InteractionSettings, + KeywordHighlightRule, ProxySettings, RecordingSettings, RiskLevel, SearchEngine, + SearchSettings, SecuritySettings, TerminalColorsConfig, TerminalSettings, ThemeColorsConfig, + ThemeConfig, TransferSettings, TranslationSettings, ai_model_id_for_credential, + ai_model_id_for_provider, decrypt_ai_settings, encrypt_ai_settings, load_app_settings, + mask_ai_settings, merge_masked_ai_settings, normalize_ai_settings, save_app_settings, }; #[allow(unused_imports)] pub use tunnel::{ diff --git a/src-tauri/src/config/settings/ai.rs b/src-tauri/src/config/settings/ai.rs index c6a692b8..449fcf57 100644 --- a/src-tauri/src/config/settings/ai.rs +++ b/src-tauri/src/config/settings/ai.rs @@ -72,6 +72,7 @@ pub enum AiPermissionMode { Observer, Confirm, Auto, + FullAccess, } impl Default for AiPermissionMode { @@ -93,19 +94,6 @@ impl Default for ExternalMcpSessionScope { } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ExternalMcpServerMode { - Temporary, - Persistent, -} - -impl Default for ExternalMcpServerMode { - fn default() -> Self { - Self::Temporary - } -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ExternalMcpSettings { #[serde(default)] @@ -114,10 +102,6 @@ pub struct ExternalMcpSettings { pub permission_mode: AiPermissionMode, #[serde(default)] pub session_scope: ExternalMcpSessionScope, - #[serde(default)] - pub server_mode: ExternalMcpServerMode, - #[serde(default = "default_external_mcp_idle_timeout_minutes")] - pub idle_timeout_minutes: u16, } impl Default for ExternalMcpSettings { @@ -126,16 +110,10 @@ impl Default for ExternalMcpSettings { enabled: false, permission_mode: AiPermissionMode::Confirm, session_scope: ExternalMcpSessionScope::CurrentWindow, - server_mode: ExternalMcpServerMode::Temporary, - idle_timeout_minutes: default_external_mcp_idle_timeout_minutes(), } } } -fn default_external_mcp_idle_timeout_minutes() -> u16 { - 10 -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum AiMode { @@ -738,8 +716,6 @@ pub fn normalize_ai_settings(settings: &mut AiSettings) -> bool { let original = serde_json::to_string(settings).unwrap_or_default(); settings.schema_version = 6; - settings.external_mcp.idle_timeout_minutes = - settings.external_mcp.idle_timeout_minutes.clamp(1, 120); if settings.request_user_agent.trim().is_empty() { settings.request_user_agent = default_request_user_agent(); } @@ -879,6 +855,38 @@ fn migrate_legacy_ollama_base_url(base_url: &mut Option) { mod tests { use super::*; + #[test] + fn legacy_external_mcp_mode_fields_are_read_but_not_written() { + let settings: ExternalMcpSettings = serde_json::from_value(serde_json::json!({ + "enabled": true, + "permission_mode": "confirm", + "session_scope": "current_window", + "server_mode": "temporary", + "idle_timeout_minutes": 10 + })) + .expect("legacy External MCP settings"); + + assert!(settings.enabled); + assert_eq!(settings.permission_mode, AiPermissionMode::Confirm); + assert_eq!( + settings.session_scope, + ExternalMcpSessionScope::CurrentWindow + ); + let serialized = serde_json::to_value(settings).expect("serialized External MCP settings"); + assert!(serialized.get("server_mode").is_none()); + assert!(serialized.get("idle_timeout_minutes").is_none()); + } + + #[test] + fn full_access_permission_mode_roundtrips_as_snake_case() { + let serialized = serde_json::to_string(&AiPermissionMode::FullAccess) + .expect("serialized permission mode"); + assert_eq!(serialized, "\"full_access\""); + let parsed: AiPermissionMode = + serde_json::from_str(&serialized).expect("parsed permission mode"); + assert_eq!(parsed, AiPermissionMode::FullAccess); + } + fn ollama_profile(settings: &AiSettings) -> &AiProviderProfile { settings .provider_profiles diff --git a/src-tauri/src/config/settings/interaction.rs b/src-tauri/src/config/settings/interaction.rs index c5b6b01e..d4cd9265 100644 --- a/src-tauri/src/config/settings/interaction.rs +++ b/src-tauri/src/config/settings/interaction.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Deserializer, Serialize}; pub struct InteractionSettings { pub copy_on_select: bool, pub allow_osc52_clipboard_write: bool, - pub right_click_paste: bool, + pub terminal_right_click_action: String, pub terminal_zoom_enabled: bool, pub command_suggestions_enabled: bool, pub command_suggestion_min_chars: usize, @@ -23,6 +23,7 @@ pub struct InteractionSettings { struct InteractionSettingsWire { copy_on_select: Option, allow_osc52_clipboard_write: Option, + terminal_right_click_action: Option, right_click_paste: Option, terminal_zoom_enabled: Option, command_suggestions_enabled: Option, @@ -58,6 +59,28 @@ fn default_encoding() -> String { "UTF-8".to_string() } +fn default_terminal_right_click_action() -> String { + "menu".to_string() +} + +fn normalize_terminal_right_click_action( + action: Option, + legacy_right_click_paste: Option, +) -> String { + if let Some(action) = action { + return match action.as_str() { + "none" | "menu" | "paste" => action, + _ => default_terminal_right_click_action(), + }; + } + + match legacy_right_click_paste { + Some(true) => "paste".to_string(), + Some(false) => "menu".to_string(), + None => "paste".to_string(), + } +} + fn default_tab_double_click_action() -> String { "disconnect_session".to_string() } @@ -79,7 +102,7 @@ impl Default for InteractionSettings { Self { copy_on_select: false, allow_osc52_clipboard_write: false, - right_click_paste: false, + terminal_right_click_action: default_terminal_right_click_action(), terminal_zoom_enabled: true, command_suggestions_enabled: true, command_suggestion_min_chars: default_command_suggestion_min_chars(), @@ -109,7 +132,10 @@ impl<'de> Deserialize<'de> for InteractionSettings { allow_osc52_clipboard_write: wire .allow_osc52_clipboard_write .unwrap_or(defaults.allow_osc52_clipboard_write), - right_click_paste: wire.right_click_paste.unwrap_or_else(default_true), + terminal_right_click_action: normalize_terminal_right_click_action( + wire.terminal_right_click_action, + wire.right_click_paste, + ), terminal_zoom_enabled: wire.terminal_zoom_enabled.unwrap_or_else(default_true), command_suggestions_enabled: wire .command_suggestions_enabled @@ -154,6 +180,7 @@ mod tests { assert_eq!(settings.tab_double_click_action, "disconnect_session"); assert_eq!(settings.tab_middle_click_action, "rename_tab"); assert_eq!(settings.tab_right_click_action, "none"); + assert_eq!(settings.terminal_right_click_action, "menu"); assert!(!settings.allow_osc52_clipboard_write); assert!(!settings.alt_as_meta); assert!(!settings.ime_compatibility); @@ -177,6 +204,7 @@ mod tests { assert_eq!(settings.tab_middle_click_action, "rename_tab"); assert_eq!(settings.tab_right_click_action, "none"); assert_eq!(settings.duplicate_session_command_delay_ms, 1000); + assert_eq!(settings.terminal_right_click_action, "menu"); assert!(!settings.allow_osc52_clipboard_write); assert!(!settings.alt_as_meta); assert!(!settings.ime_compatibility); @@ -188,12 +216,65 @@ mod tests { let settings: InteractionSettings = serde_json::from_value(serde_json::json!({})).unwrap(); assert!(settings.copy_on_select); - assert!(settings.right_click_paste); + assert_eq!(settings.terminal_right_click_action, "paste"); assert!(settings.terminal_zoom_enabled); assert!(settings.command_suggestions_enabled); assert!(!settings.ime_compatibility); } + #[test] + fn terminal_right_click_action_accepts_all_supported_values() { + for action in ["none", "menu", "paste"] { + let settings: InteractionSettings = serde_json::from_value(serde_json::json!({ + "terminal_right_click_action": action + })) + .unwrap(); + + assert_eq!(settings.terminal_right_click_action, action); + } + } + + #[test] + fn legacy_right_click_paste_migrates_to_terminal_right_click_action() { + let menu: InteractionSettings = serde_json::from_value(serde_json::json!({ + "right_click_paste": false + })) + .unwrap(); + let paste: InteractionSettings = serde_json::from_value(serde_json::json!({ + "right_click_paste": true + })) + .unwrap(); + + assert_eq!(menu.terminal_right_click_action, "menu"); + assert_eq!(paste.terminal_right_click_action, "paste"); + } + + #[test] + fn terminal_right_click_action_takes_precedence_and_normalizes_invalid_values() { + let explicit: InteractionSettings = serde_json::from_value(serde_json::json!({ + "terminal_right_click_action": "none", + "right_click_paste": true + })) + .unwrap(); + let invalid: InteractionSettings = serde_json::from_value(serde_json::json!({ + "terminal_right_click_action": "invalid", + "right_click_paste": true + })) + .unwrap(); + + assert_eq!(explicit.terminal_right_click_action, "none"); + assert_eq!(invalid.terminal_right_click_action, "menu"); + } + + #[test] + fn terminal_right_click_action_serialization_omits_legacy_field() { + let settings = InteractionSettings::default(); + let value = serde_json::to_value(settings).unwrap(); + + assert_eq!(value["terminal_right_click_action"], "menu"); + assert!(value.get("right_click_paste").is_none()); + } + #[test] fn legacy_mac_ime_compatibility_migrates_to_ime_compatibility() { let settings: InteractionSettings = serde_json::from_value(serde_json::json!({ diff --git a/src-tauri/src/config/settings/mod.rs b/src-tauri/src/config/settings/mod.rs index 05772102..e9f5461e 100644 --- a/src-tauri/src/config/settings/mod.rs +++ b/src-tauri/src/config/settings/mod.rs @@ -16,9 +16,9 @@ pub use ai::{ AiBackendKind, AiCustomActionConfig, AiMode, AiModelConfigItem, AiModelSource, AiPermissionMode, AiProviderCredential, AiProviderKind, AiProviderProfile, AiReasoningEffort, AiSettings, ClaudeCodeIntegrationSettings, CodexIntegrationSettings, CodexThreadMode, - ExternalMcpServerMode, ExternalMcpSessionScope, ExternalMcpSettings, RiskLevel, - ai_model_id_for_credential, ai_model_id_for_provider, decrypt_ai_settings, encrypt_ai_settings, - mask_ai_settings, merge_masked_ai_settings, normalize_ai_settings, + ExternalMcpSessionScope, ExternalMcpSettings, RiskLevel, ai_model_id_for_credential, + ai_model_id_for_provider, decrypt_ai_settings, encrypt_ai_settings, mask_ai_settings, + merge_masked_ai_settings, normalize_ai_settings, }; pub use appearance::{AppearanceSettings, TerminalColorsConfig, ThemeColorsConfig, ThemeConfig}; pub use diagnostics::{DiagnosticsLogLevel, DiagnosticsSettings}; @@ -95,6 +95,10 @@ pub fn load_app_settings(app: &AppHandle) -> AppResult { interaction.contains_key("mac_ime_compatibility") && !interaction.contains_key("ime_compatibility") }); + let has_legacy_terminal_right_click_action = raw_settings + .get("interaction") + .and_then(|interaction| interaction.as_object()) + .is_some_and(|interaction| !interaction.contains_key("terminal_right_click_action")); let mut migrated = false; let mut secrets_ready_for_persist = true; @@ -151,6 +155,9 @@ pub fn load_app_settings(app: &AppHandle) -> AppResult { if has_legacy_mac_ime_compatibility { migrated = true; } + if has_legacy_terminal_right_click_action { + migrated = true; + } for list in [ &mut settings.ui.activity_bar_layout.left_top, diff --git a/src-tauri/src/config/settings/transfer.rs b/src-tauri/src/config/settings/transfer.rs index 6dd223ca..b854708b 100644 --- a/src-tauri/src/config/settings/transfer.rs +++ b/src-tauri/src/config/settings/transfer.rs @@ -9,6 +9,8 @@ pub struct TransferSettings { pub editor_type: String, #[serde(default = "default_internal_editor_display")] pub internal_editor_display: String, + #[serde(default = "default_internal_editor_font_size")] + pub internal_editor_font_size: u32, #[serde(default = "default_transfer_threads")] pub download_threads: u32, #[serde(default = "default_transfer_threads")] @@ -54,6 +56,9 @@ fn default_editor_type() -> String { fn default_internal_editor_display() -> String { "workspace".to_string() } +fn default_internal_editor_font_size() -> u32 { + 13 +} fn default_duplicate_strategy() -> String { "ask".to_string() } @@ -75,6 +80,7 @@ impl Default for TransferSettings { Self { editor_type: default_editor_type(), internal_editor_display: default_internal_editor_display(), + internal_editor_font_size: default_internal_editor_font_size(), download_threads: default_transfer_threads(), upload_threads: default_transfer_threads(), duplicate_strategy: default_duplicate_strategy(), @@ -113,5 +119,6 @@ mod tests { assert!(!settings.recording_auto_start); assert_eq!(settings.editor_type, "external"); assert_eq!(settings.internal_editor_display, "workspace"); + assert_eq!(settings.internal_editor_font_size, 13); } } diff --git a/src-tauri/src/core/ai/agent.rs b/src-tauri/src/core/ai/agent.rs index fd4d16f2..ce7847a5 100644 --- a/src-tauri/src/core/ai/agent.rs +++ b/src-tauri/src/core/ai/agent.rs @@ -368,6 +368,7 @@ fn safe_command_preview(command: &str) -> String { struct RiskAssessment { model_risk: RiskLevel, local_risk: RiskLevel, + local_auto_executable: bool, effective_risk: RiskLevel, risk_reason: Option, } @@ -574,14 +575,14 @@ fn risk_label(risk: &RiskLevel) -> &'static str { } } -fn assess_local_command_risk(command: &str) -> (RiskLevel, String) { +fn assess_local_command_risk(command: &str) -> (RiskLevel, String, bool) { let risk = crate::core::capabilities::assess_command_risk(command); - (risk.level, risk.reason) + (risk.level, risk.reason, risk.auto_executable) } fn assess_agent_command_risk(parsed: &AgentLlmResponse, command: &str) -> RiskAssessment { let model_risk = parsed.risk_level.clone().unwrap_or(RiskLevel::Medium); - let (local_risk, local_reason) = assess_local_command_risk(command); + let (local_risk, local_reason, local_auto_executable) = assess_local_command_risk(command); let effective_risk = max_risk(model_risk.clone(), local_risk.clone()); let risk_reason = parsed .risk_reason @@ -593,6 +594,7 @@ fn assess_agent_command_risk(parsed: &AgentLlmResponse, command: &str) -> RiskAs RiskAssessment { model_risk, local_risk, + local_auto_executable, effective_risk, risk_reason, } @@ -636,13 +638,23 @@ fn decide_agent_command_execution( fn decide_external_agent_command_execution( mode: &AiPermissionMode, + assessment: &RiskAssessment, ) -> (ApprovalDecision, Option) { match mode { AiPermissionMode::Observer | AiPermissionMode::Confirm => ( ApprovalDecision::NeedsApproval, Some("external agent permission mode requires confirmation".to_string()), ), - AiPermissionMode::Auto => (ApprovalDecision::Auto, None), + AiPermissionMode::Auto + if assessment.local_auto_executable && assessment.effective_risk < RiskLevel::High => + { + (ApprovalDecision::Auto, None) + } + AiPermissionMode::Auto => ( + ApprovalDecision::NeedsApproval, + Some("safe auto requires confirmation for unknown or high-risk commands".to_string()), + ), + AiPermissionMode::FullAccess => (ApprovalDecision::Auto, None), } } @@ -743,7 +755,8 @@ fn append_agent_command_audit( client: None, capability: None, session_id: None, - permission_mode: None, + permission_mode: (request.agent_kind != AiAgentKind::Nyaterm) + .then(|| request.permission_mode.clone()), approval_decision: None, success: None, duration_ms: None, @@ -782,7 +795,7 @@ pub(super) async fn run_external_agent_command_step( let (decision, approval_reason) = if request.agent_kind == AiAgentKind::Nyaterm { decide_agent_command_execution(settings, &assessment) } else { - decide_external_agent_command_execution(&request.permission_mode) + decide_external_agent_command_execution(&request.permission_mode, &assessment) }; if request.agent_kind != AiAgentKind::Nyaterm @@ -1412,16 +1425,32 @@ mod tests { #[test] fn external_agent_permission_modes_are_explicit() { + let safe = assess_agent_command_risk(&parsed_response(None), "ls -la"); + let high = assess_agent_command_risk(&parsed_response(None), "sudo reboot"); + let unknown = assess_agent_command_risk(&parsed_response(None), "custom-deploy production"); + assert_eq!( - decide_external_agent_command_execution(&AiPermissionMode::Observer).0, + decide_external_agent_command_execution(&AiPermissionMode::Observer, &safe).0, ApprovalDecision::NeedsApproval ); assert_eq!( - decide_external_agent_command_execution(&AiPermissionMode::Confirm).0, + decide_external_agent_command_execution(&AiPermissionMode::Confirm, &safe).0, ApprovalDecision::NeedsApproval ); assert_eq!( - decide_external_agent_command_execution(&AiPermissionMode::Auto).0, + decide_external_agent_command_execution(&AiPermissionMode::Auto, &safe).0, + ApprovalDecision::Auto + ); + assert_eq!( + decide_external_agent_command_execution(&AiPermissionMode::Auto, &high).0, + ApprovalDecision::NeedsApproval + ); + assert_eq!( + decide_external_agent_command_execution(&AiPermissionMode::Auto, &unknown).0, + ApprovalDecision::NeedsApproval + ); + assert_eq!( + decide_external_agent_command_execution(&AiPermissionMode::FullAccess, &high).0, ApprovalDecision::Auto ); } diff --git a/src-tauri/src/core/ai/external/claude_code.rs b/src-tauri/src/core/ai/external/claude_code.rs index 7b2a4973..1ebbe15f 100644 --- a/src-tauri/src/core/ai/external/claude_code.rs +++ b/src-tauri/src/core/ai/external/claude_code.rs @@ -419,6 +419,9 @@ fn claude_permission_mode(mode: &AiPermissionMode) -> &'static str { AiPermissionMode::Observer => "plan", AiPermissionMode::Confirm => "manual", AiPermissionMode::Auto => "auto", + // Full access only bypasses NyaTerm's own capability approvals. Do not + // widen Claude Code's native local-tool permissions. + AiPermissionMode::FullAccess => "auto", } } @@ -895,6 +898,21 @@ mod tests { ); } + #[test] + fn full_access_does_not_enable_claude_native_permission_bypass() { + let mut request = test_request(); + request.permission_mode = AiPermissionMode::FullAccess; + + let invocation = + build_claude_invocation(&request, &AiSettings::default(), "prompt".to_string()); + + assert_eq!( + arg_value(&invocation.args, "--permission-mode"), + Some("auto") + ); + assert!(!invocation.args.iter().any(|arg| arg == "bypassPermissions")); + } + #[test] fn extracts_delta_from_partial_message() { let mut last = String::new(); diff --git a/src-tauri/src/core/capabilities/catalog.rs b/src-tauri/src/core/capabilities/catalog.rs index 3add6729..d3dd3893 100644 --- a/src-tauri/src/core/capabilities/catalog.rs +++ b/src-tauri/src/core/capabilities/catalog.rs @@ -1,136 +1,28 @@ -use nyaterm_mcp_protocol::{capability, tool}; +pub use nyaterm_mcp_protocol::CapabilityAccess; +use nyaterm_mcp_protocol::{McpToolDefinition, definition_for_tool}; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CapabilityAccess { - Read, - SensitiveRead, - Write, - DestructiveWrite, -} - -#[derive(Debug, Clone, Copy)] -pub struct CapabilityDefinition { - pub id: &'static str, - pub mcp_tool: Option<&'static str>, - pub access: CapabilityAccess, - pub requires_session: bool, -} - -pub const CATALOG: &[CapabilityDefinition] = &[ - CapabilityDefinition { - id: capability::ENVIRONMENT, - mcp_tool: Some(tool::GET_ENVIRONMENT), - access: CapabilityAccess::Read, - requires_session: false, - }, - CapabilityDefinition { - id: capability::SESSION_GET, - mcp_tool: Some(tool::SESSION_GET), - access: CapabilityAccess::Read, - requires_session: true, - }, - CapabilityDefinition { - id: capability::TERMINAL_EXECUTE, - mcp_tool: Some(tool::TERMINAL_EXECUTE), - access: CapabilityAccess::Write, - requires_session: true, - }, - CapabilityDefinition { - id: capability::TERMINAL_RECENT_OUTPUT, - mcp_tool: Some(tool::TERMINAL_RECENT_OUTPUT), - access: CapabilityAccess::SensitiveRead, - requires_session: true, - }, - CapabilityDefinition { - id: capability::SFTP_HOME, - mcp_tool: Some(tool::SFTP_HOME), - access: CapabilityAccess::SensitiveRead, - requires_session: true, - }, - CapabilityDefinition { - id: capability::SFTP_LIST, - mcp_tool: Some(tool::SFTP_LIST), - access: CapabilityAccess::SensitiveRead, - requires_session: true, - }, - CapabilityDefinition { - id: capability::SFTP_STAT, - mcp_tool: Some(tool::SFTP_STAT), - access: CapabilityAccess::SensitiveRead, - requires_session: true, - }, - CapabilityDefinition { - id: capability::SFTP_READ, - mcp_tool: Some(tool::SFTP_READ_TEXT), - access: CapabilityAccess::SensitiveRead, - requires_session: true, - }, - CapabilityDefinition { - id: capability::SFTP_WRITE, - mcp_tool: Some(tool::SFTP_WRITE_TEXT), - access: CapabilityAccess::Write, - requires_session: true, - }, - CapabilityDefinition { - id: capability::SFTP_MKDIR, - mcp_tool: Some(tool::SFTP_MKDIR), - access: CapabilityAccess::Write, - requires_session: true, - }, - CapabilityDefinition { - id: capability::SFTP_RENAME, - mcp_tool: Some(tool::SFTP_RENAME), - access: CapabilityAccess::Write, - requires_session: true, - }, - CapabilityDefinition { - id: capability::SFTP_DELETE, - mcp_tool: Some(tool::SFTP_DELETE), - access: CapabilityAccess::DestructiveWrite, - requires_session: true, - }, - CapabilityDefinition { - id: capability::SFTP_CHMOD, - mcp_tool: Some(tool::SFTP_CHMOD), - access: CapabilityAccess::Write, - requires_session: true, - }, - CapabilityDefinition { - id: capability::OUTPUT_READ, - mcp_tool: Some(tool::OUTPUT_READ), - access: CapabilityAccess::SensitiveRead, - requires_session: false, - }, -]; - -#[cfg(test)] -fn capability_by_id(id: &str) -> Option<&'static CapabilityDefinition> { - CATALOG.iter().find(|definition| definition.id == id) -} - -pub fn capability_for_tool(name: &str) -> Option<&'static CapabilityDefinition> { - CATALOG - .iter() - .find(|definition| definition.mcp_tool == Some(name)) +pub fn capability_for_tool(name: &str) -> Option<&'static McpToolDefinition> { + definition_for_tool(name) } #[cfg(test)] mod tests { + use nyaterm_mcp_protocol::{CapabilityAccess, MCP_TOOL_REGISTRY, capability, tool}; + use super::*; #[test] - fn delete_is_destructive_and_all_tools_are_unique() { + fn every_registered_tool_has_a_host_capability() { + for definition in MCP_TOOL_REGISTRY { + assert_eq!(capability_for_tool(definition.tool), Some(definition)); + } assert_eq!( - capability_by_id(capability::SFTP_DELETE).unwrap().access, + capability_for_tool(tool::SFTP_DELETE).unwrap().capability, + capability::SFTP_DELETE + ); + assert_eq!( + capability_for_tool(tool::SFTP_DELETE).unwrap().access, CapabilityAccess::DestructiveWrite ); - let mut tools = CATALOG - .iter() - .filter_map(|item| item.mcp_tool) - .collect::>(); - let before = tools.len(); - tools.sort_unstable(); - tools.dedup(); - assert_eq!(tools.len(), before); } } diff --git a/src-tauri/src/core/capabilities/mod.rs b/src-tauri/src/core/capabilities/mod.rs index 3fe4d35f..329b2144 100644 --- a/src-tauri/src/core/capabilities/mod.rs +++ b/src-tauri/src/core/capabilities/mod.rs @@ -8,9 +8,9 @@ mod terminal; pub use catalog::{CapabilityAccess, capability_for_tool}; pub use output_store::OutputStore; -pub use policy::{PolicyDecision, assess_command_risk, decide_policy}; +pub use policy::{PolicyDecision, RiskAssessment, assess_command_risk, decide_policy}; pub use recent_output::RecentOutputStore; -pub use scope::McpScope; +pub use scope::{McpScope, McpScopeSnapshot}; pub use terminal::{ TerminalExecuteRequest, TerminalExecutionPresentation, execute_terminal_command, }; diff --git a/src-tauri/src/core/capabilities/policy.rs b/src-tauri/src/core/capabilities/policy.rs index fc1e9c69..787c8246 100644 --- a/src-tauri/src/core/capabilities/policy.rs +++ b/src-tauri/src/core/capabilities/policy.rs @@ -10,16 +10,20 @@ pub enum PolicyDecision { } #[derive(Debug, Clone)] -pub struct CommandRisk { +pub struct RiskAssessment { pub level: RiskLevel, pub reason: String, + pub auto_executable: bool, } pub fn decide_policy( mode: &AiPermissionMode, access: CapabilityAccess, - command_risk: Option<&RiskLevel>, + assessment: Option<&RiskAssessment>, ) -> PolicyDecision { + if *mode == AiPermissionMode::FullAccess { + return PolicyDecision::Allow; + } if matches!( access, CapabilityAccess::Write | CapabilityAccess::DestructiveWrite @@ -30,10 +34,14 @@ pub fn decide_policy( if access == CapabilityAccess::DestructiveWrite { return PolicyDecision::RequireApproval; } - if command_risk.is_some_and(|risk| *risk >= RiskLevel::High) { + if assessment.is_some_and(|risk| risk.level >= RiskLevel::High) { + return PolicyDecision::RequireApproval; + } + if *mode == AiPermissionMode::Auto && assessment.is_some_and(|risk| !risk.auto_executable) { return PolicyDecision::RequireApproval; } match (mode, access) { + (AiPermissionMode::FullAccess, _) => PolicyDecision::Allow, (_, CapabilityAccess::Read) => PolicyDecision::Allow, (AiPermissionMode::Auto, CapabilityAccess::SensitiveRead | CapabilityAccess::Write) => { PolicyDecision::Allow @@ -45,110 +53,93 @@ pub fn decide_policy( } } -pub fn assess_command_risk(command: &str) -> CommandRisk { - let normalized = command - .trim() - .replace("\r\n", "\n") - .replace('\n', " ") - .to_ascii_lowercase(); - let compact = normalized.split_whitespace().collect::>().join(" "); - if compact.is_empty() { - return risk(RiskLevel::Medium, "empty command"); +pub fn assess_command_risk(command: &str) -> RiskAssessment { + let normalized = command.trim().replace("\r\n", "\n").replace('\r', "\n"); + if normalized.is_empty() { + return risk(RiskLevel::Medium, "empty command", false); } - if is_root_rm_command(&compact) - || (compact.starts_with("dd ") && compact.contains("of=/dev/")) - || contains_any( - &compact, - &[ - "mkfs", - "wipefs", - ":(){", - "shutdown", - "poweroff", - "reboot", - "halt", - "systemctl stop ssh", - "systemctl stop sshd", - "service ssh stop", - "service sshd stop", - ], - ) + if normalized + .split_whitespace() + .collect::() + .contains(":(){:|:&};:") { return risk( RiskLevel::Critical, "matches irreversible or system-disruptive command pattern", + false, ); } - if compact.starts_with("sudo ") - || contains_any( - &compact, - &[ - "rm -r", - "rm -f", - " rmdir ", - " chmod -r", - " chown -r", - "systemctl restart", - "systemctl stop", - "service ", - "apt install", - "apt remove", - "apt purge", - "yum install", - "yum remove", - "dnf install", - "dnf remove", - "pacman -s", - "pacman -r", - "brew install", - "brew uninstall", - "npm install -g", - "pip install", - "docker rm", - "docker rmi", - "docker system prune", - "kubectl delete", - "kubectl drain", - "kubectl apply", - "kubectl replace", - "git reset --hard", - "git clean -fd", - ], - ) + let tokens = tokenize_shell(&normalized.to_ascii_lowercase()); + if tokens.is_empty() { + return risk(RiskLevel::Medium, "command could not be classified", false); + } + let stages = command_stages(&tokens); + if stages.is_empty() { + return risk(RiskLevel::Medium, "command could not be classified", false); + } + + if stages.iter().any(|stage| is_critical_stage(stage)) { + return risk( + RiskLevel::Critical, + "matches irreversible or system-disruptive command pattern", + false, + ); + } + if is_download_pipe_to_shell(&tokens) + || has_sensitive_write_redirection(&tokens) + || stages.iter().any(|stage| is_high_risk_stage(stage)) { return risk( RiskLevel::High, - "matches privileged, destructive, restart, package, container, or cluster mutation pattern", + "matches privileged or high-impact mutation pattern", + false, ); } - if contains_any( - &format!(" {compact} "), - &[ - " > ", - ">>", - " tee ", - " touch ", - " mkdir ", - " cp ", - " mv ", - " chmod ", - " chown ", - " setfacl ", - " export ", - "git checkout", - "git switch", - "git pull", - "git merge", - "npm run", - "make install", - ], - ) { - return risk( + + let has_redirection = tokens + .iter() + .any(|token| matches!(token.as_str(), ">" | ">>")); + let mut saw_write = has_redirection; + for stage in &stages { + match classify_stage(stage) { + StageClass::ReadOnly => {} + StageClass::Write => saw_write = true, + StageClass::Unknown => { + return risk( + RiskLevel::Medium, + "command is not explicitly classified as safe for automatic execution", + false, + ); + } + } + } + if saw_write { + risk( RiskLevel::Medium, - "matches local write or state-changing command pattern", - ); + "matches a known ordinary state-changing command pattern", + true, + ) + } else { + risk( + RiskLevel::Low, + "matches read-only diagnostic command patterns", + true, + ) } - let readonly = [ +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StageClass { + ReadOnly, + Write, + Unknown, +} + +fn classify_stage(stage: &[String]) -> StageClass { + let Some((command, args)) = executable_and_args(stage) else { + return StageClass::Unknown; + }; + let read_only = [ "ls", "pwd", "whoami", @@ -165,65 +156,439 @@ pub fn assess_command_risk(command: &str) -> CommandRisk { "du", "free", "top", + "htop", "ps", "ss", "netstat", - "ip ", "journalctl", - "systemctl status", - "docker ps", - "docker logs", - "kubectl get", - "kubectl describe", - "git status", - "git log", - "git diff", + "printenv", + "which", + "whereis", ]; - if readonly - .iter() - .any(|prefix| compact == prefix.trim() || compact.starts_with(&format!("{prefix} "))) - { - return risk(RiskLevel::Low, "matches read-only diagnostic pattern"); + if read_only.contains(&command) { + return StageClass::ReadOnly; } - risk( - RiskLevel::Medium, - "no explicit read-only pattern matched; defaulting to medium", - ) + if command == "env" && args.is_empty() { + return StageClass::ReadOnly; + } + if command == "ip" + && (args.is_empty() + || args.first().is_some_and(|arg| { + matches!( + arg.as_str(), + "a" | "addr" | "address" | "link" | "route" | "neigh" | "rule" + ) + })) + { + return StageClass::ReadOnly; + } + if command == "systemctl" && args.first().is_some_and(|arg| arg == "status") { + return StageClass::ReadOnly; + } + if command == "docker" + && args + .first() + .is_some_and(|arg| matches!(arg.as_str(), "ps" | "logs" | "inspect")) + { + return StageClass::ReadOnly; + } + if command == "kubectl" + && args + .first() + .is_some_and(|arg| matches!(arg.as_str(), "get" | "describe" | "logs" | "explain")) + { + return StageClass::ReadOnly; + } + if command == "git" + && args + .first() + .is_some_and(|arg| matches!(arg.as_str(), "status" | "log" | "diff" | "show")) + { + return StageClass::ReadOnly; + } + + let ordinary_write = [ + "touch", "mkdir", "cp", "mv", "chmod", "chown", "setfacl", "export", + ]; + if ordinary_write.contains(&command) { + return StageClass::Write; + } + if command == "git" + && args.first().is_some_and(|arg| { + matches!( + arg.as_str(), + "checkout" | "switch" | "pull" | "merge" | "add" | "commit" + ) + }) + { + return StageClass::Write; + } + if command == "make" && args.iter().any(|arg| arg == "install") { + return StageClass::Write; + } + StageClass::Unknown } -fn risk(level: RiskLevel, reason: &str) -> CommandRisk { - CommandRisk { +fn is_critical_stage(stage: &[String]) -> bool { + let Some((command, args)) = executable_and_args(stage) else { + return false; + }; + if command == "rm" && is_root_rm_args(args) { + return true; + } + if command == "dd" && args.iter().any(|arg| arg.starts_with("of=/dev/")) { + return true; + } + if matches!( + command, + "mkfs" | "wipefs" | "shutdown" | "poweroff" | "reboot" | "halt" + ) || command.starts_with("mkfs.") + { + return true; + } + (command == "systemctl" + && args.first().is_some_and(|arg| arg == "stop") + && args.iter().skip(1).any(|arg| { + matches!( + arg.as_str(), + "ssh" | "sshd" | "ssh.service" | "sshd.service" + ) + })) + || (command == "service" + && args + .first() + .is_some_and(|arg| matches!(arg.as_str(), "ssh" | "sshd")) + && args.get(1).is_some_and(|arg| arg == "stop")) + || stage.join("").contains(":(){:|:&};:") +} + +fn is_high_risk_stage(stage: &[String]) -> bool { + let Some((command, args)) = executable_and_args(stage) else { + return false; + }; + if matches!(command, "sudo" | "doas" | "su") { + return true; + } + if matches!( + command, + "rm" | "rmdir" + | "truncate" + | "iptables" + | "ip6tables" + | "nft" + | "ufw" + | "useradd" + | "userdel" + | "usermod" + | "passwd" + | "visudo" + ) { + return true; + } + if command == "find" + && args + .iter() + .any(|arg| matches!(arg.as_str(), "-delete" | "-exec" | "-execdir")) + { + return true; + } + if command == "ip" + && args.iter().any(|arg| { + matches!( + arg.as_str(), + "add" | "delete" | "del" | "replace" | "change" | "set" | "flush" + ) + }) + { + return true; + } + if matches!(command, "chmod" | "chown") + && args + .iter() + .any(|arg| arg.starts_with('-') && (arg.contains('r') || arg.contains('R'))) + { + return true; + } + if matches!(command, "cp" | "mv" | "chmod" | "chown") + && args.iter().any(|arg| is_sensitive_terminal_path(arg)) + { + return true; + } + if command == "sed" && args.iter().any(|arg| arg == "-i" || arg.starts_with("-i")) { + return true; + } + if command == "perl" + && args.iter().any(|arg| { + arg.starts_with('-') + && arg.chars().any(|flag| flag == 'p') + && arg.chars().any(|flag| flag == 'i') + }) + { + return true; + } + if command == "systemctl" + && args + .first() + .is_some_and(|arg| matches!(arg.as_str(), "restart" | "stop" | "disable" | "mask")) + { + return true; + } + if command == "service" { + return true; + } + if matches!( + command, + "apt" | "apt-get" | "yum" | "dnf" | "pacman" | "brew" | "pip" | "pip3" + ) && args.iter().any(|arg| { + matches!( + arg.as_str(), + "install" | "remove" | "purge" | "uninstall" | "-s" | "-r" + ) + }) { + return true; + } + if command == "npm" && args.iter().any(|arg| arg == "-g" || arg == "--global") { + return true; + } + if command == "docker" { + return matches!(args.first().map(String::as_str), Some("rm" | "rmi")) + || args.starts_with(&["system".into(), "prune".into()]) + || args.starts_with(&["compose".into(), "down".into()]); + } + if command == "kubectl" + && args + .first() + .is_some_and(|arg| matches!(arg.as_str(), "delete" | "drain" | "apply" | "replace")) + { + return true; + } + if command == "terraform" + && args + .first() + .is_some_and(|arg| matches!(arg.as_str(), "apply" | "destroy")) + { + return true; + } + if command == "helm" && args.first().is_some_and(|arg| arg == "uninstall") { + return true; + } + if command == "git" + && ((args.first().is_some_and(|arg| arg == "reset") + && args.iter().any(|arg| arg == "--hard")) + || (args.first().is_some_and(|arg| arg == "clean") + && args + .iter() + .any(|arg| arg.starts_with('-') && arg.contains('f')))) + { + return true; + } + if matches!(command, "mysql" | "psql") && contains_sql_word(args, "drop") { + return true; + } + command == "redis-cli" + && args + .iter() + .any(|arg| matches!(arg.as_str(), "flushall" | "flushdb")) +} + +fn is_download_pipe_to_shell(tokens: &[String]) -> bool { + tokens.iter().enumerate().any(|(index, token)| { + if token != "|" { + return false; + } + let left_start = tokens[..index] + .iter() + .rposition(|token| matches!(token.as_str(), ";" | "&" | "&&" | "||" | "|")) + .map_or(0, |position| position + 1); + let right_end = tokens[index + 1..] + .iter() + .position(|token| matches!(token.as_str(), ";" | "&" | "&&" | "||" | "|")) + .map_or(tokens.len(), |position| index + 1 + position); + let left = executable_and_args(&tokens[left_start..index]).map(|value| value.0); + let right = executable_and_args(&tokens[index + 1..right_end]).map(|value| value.0); + matches!(left, Some("curl" | "wget")) + && matches!(right, Some("sh" | "bash" | "zsh" | "fish")) + }) +} + +fn has_sensitive_write_redirection(tokens: &[String]) -> bool { + tokens + .windows(2) + .any(|pair| matches!(pair[0].as_str(), ">" | ">>") && is_sensitive_terminal_path(&pair[1])) +} + +fn executable_and_args(stage: &[String]) -> Option<(&str, &[String])> { + let mut index = 0; + while stage + .get(index) + .is_some_and(|token| token.contains('=') && !token.starts_with('=')) + { + index += 1; + } + let mut command = stage.get(index)?; + if basename(command) == "env" { + index += 1; + while stage.get(index).is_some_and(|token| { + token.starts_with('-') || (token.contains('=') && !token.starts_with('=')) + }) { + index += 1; + } + let Some(nested) = stage.get(index) else { + return Some(("env", &[])); + }; + command = nested; + } + Some((basename(command), &stage[index + 1..])) +} + +fn is_sensitive_terminal_path(value: &str) -> bool { + [ + "/etc", "/boot", "/bin", "/sbin", "/usr", "/lib", "/lib64", "/var/lib", "/root", + ] + .iter() + .any(|root| value == *root || value.starts_with(&format!("{root}/"))) + || value == "~/.ssh" + || value.starts_with("~/.ssh/") + || value.contains("/.ssh/") +} + +fn basename(command: &str) -> &str { + command.rsplit(['/', '\\']).next().unwrap_or(command) +} + +fn is_root_rm_args(args: &[String]) -> bool { + let recursive_force = args.iter().any(|arg| { + arg.starts_with('-') + && arg.chars().any(|flag| flag == 'r') + && arg.chars().any(|flag| flag == 'f') + }); + recursive_force + && args + .iter() + .any(|arg| matches!(arg.as_str(), "/" | "/*" | "--no-preserve-root")) +} + +fn contains_sql_word(args: &[String], needle: &str) -> bool { + args.iter().any(|arg| { + arg.split(|character: char| !character.is_ascii_alphanumeric() && character != '_') + .any(|word| word == needle) + }) +} + +fn command_stages(tokens: &[String]) -> Vec> { + let mut stages = Vec::new(); + let mut current = Vec::new(); + for token in tokens { + if matches!(token.as_str(), ";" | "&" | "&&" | "||" | "|") { + if !current.is_empty() { + stages.push(std::mem::take(&mut current)); + } + } else if !matches!(token.as_str(), ">" | ">>") { + current.push(token.clone()); + } + } + if !current.is_empty() { + stages.push(current); + } + stages +} + +fn tokenize_shell(command: &str) -> Vec { + let mut tokens = Vec::new(); + let mut current = String::new(); + let mut quote = None; + let mut escaped = false; + let mut chars = command.chars().peekable(); + while let Some(character) = chars.next() { + if escaped { + current.push(character); + escaped = false; + continue; + } + if character == '\\' && quote != Some('\'') { + escaped = true; + continue; + } + if matches!(character, '\'' | '"') { + if quote == Some(character) { + quote = None; + } else if quote.is_none() { + quote = Some(character); + } else { + current.push(character); + } + continue; + } + if quote.is_none() && character == '\n' { + push_token(&mut tokens, &mut current); + tokens.push(";".into()); + continue; + } + if quote.is_none() && character.is_whitespace() { + push_token(&mut tokens, &mut current); + continue; + } + if quote.is_none() && matches!(character, ';' | '|' | '&' | '>') { + push_token(&mut tokens, &mut current); + let mut operator = character.to_string(); + if chars.peek() == Some(&character) && matches!(character, '|' | '&' | '>') { + operator.push(chars.next().unwrap()); + } + tokens.push(operator); + continue; + } + current.push(character); + } + push_token(&mut tokens, &mut current); + tokens +} + +fn push_token(tokens: &mut Vec, current: &mut String) { + if !current.is_empty() { + tokens.push(std::mem::take(current)); + } +} + +pub(crate) fn risk(level: RiskLevel, reason: &str, auto_executable: bool) -> RiskAssessment { + RiskAssessment { level, reason: reason.to_string(), + auto_executable, } } -fn contains_any(command: &str, patterns: &[&str]) -> bool { - patterns.iter().any(|pattern| command.contains(pattern)) -} - -fn is_root_rm_command(command: &str) -> bool { - let tokens = command.split_whitespace().collect::>(); - tokens.first() == Some(&"rm") - && tokens - .iter() - .any(|token| token.starts_with('-') && token.contains('r') && token.contains('f')) - && tokens - .iter() - .skip(1) - .any(|token| matches!(*token, "/" | "/*" | "--no-preserve-root")) -} - #[cfg(test)] mod tests { use super::*; #[test] fn permission_matrix_is_conservative() { + let safe = risk(RiskLevel::Medium, "known write", true); + let unknown = risk(RiskLevel::Medium, "unknown", false); + let high = risk(RiskLevel::High, "high", false); + for mode in [ + AiPermissionMode::Observer, + AiPermissionMode::Confirm, + AiPermissionMode::Auto, + AiPermissionMode::FullAccess, + ] { + assert_eq!( + decide_policy(&mode, CapabilityAccess::Read, None), + PolicyDecision::Allow + ); + } assert_eq!( decide_policy(&AiPermissionMode::Observer, CapabilityAccess::Write, None), PolicyDecision::Deny ); + assert_eq!( + decide_policy( + &AiPermissionMode::Observer, + CapabilityAccess::SensitiveRead, + None + ), + PolicyDecision::RequireApproval + ); assert_eq!( decide_policy( &AiPermissionMode::Confirm, @@ -235,8 +600,24 @@ mod tests { assert_eq!( decide_policy( &AiPermissionMode::Auto, + CapabilityAccess::SensitiveRead, + None + ), + PolicyDecision::Allow + ); + assert_eq!( + decide_policy( + &AiPermissionMode::Confirm, CapabilityAccess::Write, - Some(&RiskLevel::Low) + Some(&safe) + ), + PolicyDecision::RequireApproval + ); + assert_eq!( + decide_policy( + &AiPermissionMode::Auto, + CapabilityAccess::Write, + Some(&safe) ), PolicyDecision::Allow ); @@ -244,7 +625,15 @@ mod tests { decide_policy( &AiPermissionMode::Auto, CapabilityAccess::Write, - Some(&RiskLevel::High) + Some(&unknown) + ), + PolicyDecision::RequireApproval + ); + assert_eq!( + decide_policy( + &AiPermissionMode::Auto, + CapabilityAccess::Write, + Some(&high) ), PolicyDecision::RequireApproval ); @@ -256,15 +645,114 @@ mod tests { ), PolicyDecision::RequireApproval ); + assert_eq!( + decide_policy( + &AiPermissionMode::Observer, + CapabilityAccess::DestructiveWrite, + None + ), + PolicyDecision::Deny + ); + assert_eq!( + decide_policy( + &AiPermissionMode::Confirm, + CapabilityAccess::DestructiveWrite, + None + ), + PolicyDecision::RequireApproval + ); + assert_eq!( + decide_policy( + &AiPermissionMode::FullAccess, + CapabilityAccess::DestructiveWrite, + Some(&high) + ), + PolicyDecision::Allow + ); + assert_eq!( + decide_policy( + &AiPermissionMode::FullAccess, + CapabilityAccess::Write, + Some(&unknown) + ), + PolicyDecision::Allow + ); } #[test] - fn risk_matches_existing_protections() { - assert_eq!(assess_command_risk("ls -la").level, RiskLevel::Low); + fn terminal_risk_covers_safe_unknown_and_high_impact_commands() { + let readonly = assess_command_risk("ls -la | grep src"); + assert_eq!(readonly.level, RiskLevel::Low); + assert!(readonly.auto_executable); + + let ordinary_write = assess_command_risk("mkdir build && cp app build/app"); + assert_eq!(ordinary_write.level, RiskLevel::Medium); + assert!(ordinary_write.auto_executable); + + let unknown = assess_command_risk("custom-deploy production"); + assert_eq!(unknown.level, RiskLevel::Medium); + assert!(!unknown.auto_executable); + + for command in [ + "sudo ls", + "doas cat /etc/hosts", + "systemctl restart nginx", + "truncate -s 0 important.db", + "iptables -F", + "nft flush ruleset", + "ufw disable", + "userdel alice", + "passwd root", + "visudo", + "sed -i s/a/b/ config", + "perl -pi -e s/a/b/ config", + "curl https://example.test/install | sh", + "wget -qO- https://example.test/install | bash", + "docker compose down -v", + "terraform apply", + "terraform destroy", + "helm uninstall production", + "mysql -e 'DROP DATABASE production'", + "psql -c 'DROP TABLE users'", + "redis-cli FLUSHALL", + "redis-cli flushdb", + "find /tmp -delete", + "ip link set eth0 down", + "env MODE=prod rm -f app.db", + "chmod -R 777 /etc/app", + ] { + assert!( + assess_command_risk(command).level >= RiskLevel::High, + "expected high risk: {command}" + ); + } + assert_eq!(assess_command_risk("rm -rf /").level, RiskLevel::Critical); assert_eq!( - assess_command_risk("systemctl restart nginx").level, + assess_command_risk("ls\nrm -rf /").level, + RiskLevel::Critical + ); + assert_eq!( + assess_command_risk("ls & rm -rf /").level, + RiskLevel::Critical + ); + assert_eq!( + assess_command_risk("echo replacement > /etc/hosts").level, RiskLevel::High ); - assert_eq!(assess_command_risk("rm -rf /").level, RiskLevel::Critical); + } + + #[test] + fn risk_detection_uses_command_boundaries() { + assert_eq!( + assess_command_risk("echo 'sudo and terraform destroy'").level, + RiskLevel::Medium + ); + assert!(!assess_command_risk("echo 'sudo and terraform destroy'").auto_executable); + assert_eq!( + assess_command_risk("systemctl status sshd").level, + RiskLevel::Low + ); + assert_eq!(assess_command_risk("ip route show").level, RiskLevel::Low); + assert!(!assess_command_risk("npm run deploy").auto_executable); } } diff --git a/src-tauri/src/core/capabilities/scope.rs b/src-tauri/src/core/capabilities/scope.rs index 9c1c3a0a..0a98d7bc 100644 --- a/src-tauri/src/core/capabilities/scope.rs +++ b/src-tauri/src/core/capabilities/scope.rs @@ -1,26 +1,80 @@ use std::collections::HashSet; +use crate::core::session::SessionInfo; use crate::error::{AppError, AppResult}; #[derive(Debug, Clone)] -pub struct McpScope { +pub enum McpScope { + Explicit { + session_ids: HashSet, + default_session_id: Option, + }, + CurrentWindow { + owner_window_label: String, + }, + AllSessions, +} + +#[derive(Debug, Clone)] +pub struct McpScopeSnapshot { pub session_ids: HashSet, pub default_session_id: Option, } impl McpScope { - pub fn new( + pub fn explicit( session_ids: impl IntoIterator, default_session_id: Option, ) -> Self { let session_ids = session_ids.into_iter().collect::>(); let default_session_id = default_session_id.filter(|id| session_ids.contains(id)); - Self { + Self::Explicit { session_ids, default_session_id, } } + pub fn current_window(owner_window_label: impl Into) -> Self { + Self::CurrentWindow { + owner_window_label: owner_window_label.into(), + } + } + + pub fn resolve(&self, sessions: &[SessionInfo]) -> McpScopeSnapshot { + match self { + Self::Explicit { + session_ids, + default_session_id, + } => { + let live_ids = sessions + .iter() + .map(|session| session.id.as_str()) + .collect::>(); + let session_ids = session_ids + .iter() + .filter(|id| live_ids.contains(id.as_str())) + .cloned() + .collect::>(); + let default_session_id = default_session_id + .as_ref() + .filter(|id| session_ids.contains(*id)) + .cloned(); + McpScopeSnapshot { + session_ids, + default_session_id, + } + } + Self::CurrentWindow { owner_window_label } => { + dynamic_snapshot(sessions.iter().filter(|session| { + session.owner_window_label.as_deref() == Some(owner_window_label.as_str()) + })) + } + Self::AllSessions => dynamic_snapshot(sessions.iter()), + } + } +} + +impl McpScopeSnapshot { pub fn require(&self, session_id: &str) -> AppResult<()> { if self.session_ids.contains(session_id) { Ok(()) @@ -44,20 +98,82 @@ impl McpScope { } } +fn dynamic_snapshot<'a>(sessions: impl Iterator) -> McpScopeSnapshot { + let session_ids = sessions + .map(|session| session.id.clone()) + .collect::>(); + let default_session_id = (session_ids.len() == 1) + .then(|| session_ids.iter().next().cloned()) + .flatten(); + McpScopeSnapshot { + session_ids, + default_session_id, + } +} + #[cfg(test)] mod tests { + use crate::config::AiExecutionProfile; + use crate::core::session::{DynamicTitleCapabilities, SessionType}; + use super::*; + + fn session(id: &str, owner: &str) -> SessionInfo { + SessionInfo { + id: id.into(), + name: id.into(), + session_type: SessionType::SSH, + started_at: String::new(), + connection_id: None, + connected: true, + owner_window_label: Some(owner.into()), + ai_execution_profile: AiExecutionProfile::Auto, + injection_active: true, + dynamic_title_capabilities: DynamicTitleCapabilities::default(), + remote_file_browser_enabled: true, + remote_stats_enabled: true, + ssh_profile: None, + } + } #[test] - fn resolves_only_scoped_default() { - let scope = McpScope::new(["a".into(), "b".into()], Some("a".into())); - assert_eq!(scope.resolve_terminal_session(None).unwrap(), "a"); - assert!(scope.resolve_terminal_session(Some("c")).is_err()); + fn explicit_scope_is_frozen_and_drops_closed_sessions() { + let scope = McpScope::explicit(["a".into(), "b".into()], Some("a".into())); + let initial = scope.resolve(&[session("a", "main"), session("b", "main")]); + assert_eq!(initial.resolve_terminal_session(None).unwrap(), "a"); + assert!(initial.resolve_terminal_session(Some("c")).is_err()); + + let changed = scope.resolve(&[session("b", "main"), session("c", "main")]); + assert_eq!(changed.session_ids, HashSet::from(["b".into()])); + assert!(changed.default_session_id.is_none()); + } + + #[test] + fn current_window_scope_resolves_new_sessions_and_excludes_other_windows() { + let scope = McpScope::current_window("main"); + let initial = scope.resolve(&[session("a", "main"), session("x", "main-2")]); + assert_eq!(initial.session_ids, HashSet::from(["a".into()])); + assert_eq!(initial.default_session_id.as_deref(), Some("a")); + + let changed = scope.resolve(&[ + session("a", "main"), + session("b", "main"), + session("x", "main-2"), + ]); + assert_eq!(changed.session_ids, HashSet::from(["a".into(), "b".into()])); + assert!(changed.default_session_id.is_none()); } #[test] - fn multiple_sessions_without_default_require_explicit_id() { - let scope = McpScope::new(["a".into(), "b".into()], None); - assert!(scope.resolve_terminal_session(None).is_err()); + fn all_sessions_scope_resolves_new_sessions() { + let scope = McpScope::AllSessions; + assert_eq!(scope.resolve(&[session("a", "main")]).session_ids.len(), 1); + assert_eq!( + scope + .resolve(&[session("a", "main"), session("b", "main-2")]) + .session_ids + .len(), + 2 + ); } } diff --git a/src-tauri/src/core/capabilities/sftp.rs b/src-tauri/src/core/capabilities/sftp.rs index f40b2745..746243dd 100644 --- a/src-tauri/src/core/capabilities/sftp.rs +++ b/src-tauri/src/core/capabilities/sftp.rs @@ -4,6 +4,160 @@ use crate::core::session::{SessionInfo, SessionManager, SessionType}; use crate::core::sftp::{self, FileEntry, FileProperties, RemoteTextFile, WriteRemoteTextResult}; use crate::error::{AppError, AppResult}; +use super::RiskAssessment; +use super::policy::risk; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SftpRiskOperation { + Read, + Write, + Mkdir, + Rename, + Delete, + Chmod, +} + +pub fn assess_sftp_risk( + operation: SftpRiskOperation, + path: &str, + destination_path: Option<&str>, + force: bool, + mode: Option<&str>, +) -> RiskAssessment { + if operation == SftpRiskOperation::Delete { + return risk( + crate::config::RiskLevel::High, + "remote path deletion is destructive", + false, + ); + } + if operation == SftpRiskOperation::Read { + return risk( + crate::config::RiskLevel::Medium, + "remote file access may expose sensitive data", + true, + ); + } + let (path, path_has_parent_traversal) = normalize_remote_path(path); + let (destination, destination_has_parent_traversal) = destination_path + .map(normalize_remote_path) + .unwrap_or_default(); + let sensitive = + is_sensitive_path(&path) || (!destination.is_empty() && is_sensitive_path(&destination)); + if force { + return risk( + crate::config::RiskLevel::High, + "force write bypasses optimistic concurrency protection", + false, + ); + } + if sensitive || path_has_parent_traversal || destination_has_parent_traversal { + return risk( + crate::config::RiskLevel::High, + "mutation targets a sensitive or ambiguously resolved remote path", + false, + ); + } + if operation == SftpRiskOperation::Chmod && mode.is_some_and(is_dangerous_mode) { + return risk( + crate::config::RiskLevel::Medium, + "permission change grants broad remote access", + true, + ); + } + risk( + crate::config::RiskLevel::Medium, + "ordinary remote filesystem mutation", + true, + ) +} + +fn normalize_remote_path(path: &str) -> (String, bool) { + let path = path.trim().replace('\\', "/"); + let absolute = path.starts_with('/'); + let home = path == "~" || path.starts_with("~/"); + let mut parent_traversal = false; + let mut parts = Vec::new(); + for part in path.split('/') { + match part { + "" | "." => {} + ".." => { + parent_traversal = true; + if parts.last().is_some_and(|part| *part != "~") { + parts.pop(); + } + } + _ => parts.push(part), + } + } + let joined = parts.join("/"); + let normalized = if absolute && joined.is_empty() { + "/".to_string() + } else if absolute { + format!("/{joined}") + } else if home && !joined.starts_with('~') { + format!("~/{joined}") + } else { + joined + }; + let normalized = if normalized == "/" { + normalized + } else { + normalized.trim_end_matches('/').to_string() + }; + (normalized, parent_traversal) +} + +fn is_sensitive_path(path: &str) -> bool { + const SYSTEM_ROOTS: &[&str] = &[ + "/etc", "/boot", "/bin", "/sbin", "/usr", "/lib", "/lib64", "/var/lib", "/root", + ]; + if path == "/" + || SYSTEM_ROOTS + .iter() + .any(|root| path == *root || path.starts_with(&format!("{root}/"))) + { + return true; + } + let components = path.split('/').collect::>(); + if components.contains(&".ssh") + || path == "~/.ssh" + || path.starts_with("~/.ssh/") + || path == ".ssh" + || path.starts_with(".ssh/") + || path.contains("/.ssh/") + { + return true; + } + let basename = components.last().copied().unwrap_or_default(); + matches!( + basename, + "authorized_keys" | "sshd_config" | "sudoers" | "passwd" | "shadow" | "group" | "crontab" + ) || components.iter().any(|part| { + matches!( + *part, + "sudoers.d" + | "systemd" + | "nginx" + | "cron" + | "cron.d" + | "cron.daily" + | "cron.hourly" + | "cron.monthly" + | "cron.weekly" + ) + }) || matches!( + path.rsplit_once('.').map(|(_, extension)| extension), + Some("service" | "socket" | "timer" | "target") + ) +} + +fn is_dangerous_mode(mode: &str) -> bool { + let mode = mode.trim().strip_prefix("0o").unwrap_or(mode.trim()); + u32::from_str_radix(mode.trim_start_matches('0'), 8) + .is_ok_and(|value| matches!(value, 0o666 | 0o777)) +} + pub fn is_available(info: &SessionInfo) -> bool { info.connected && info.session_type == SessionType::SSH && info.remote_file_browser_enabled } @@ -114,3 +268,81 @@ pub async fn chmod( require_available(&manager, session_id).await?; sftp::chmod_remote_file(manager, session_id, path, mode).await } + +#[cfg(test)] +mod tests { + use crate::config::RiskLevel; + + use super::*; + + #[test] + fn assesses_remote_filesystem_mutations_dynamically() { + let ordinary = assess_sftp_risk( + SftpRiskOperation::Write, + "/home/alice/notes.txt", + None, + false, + None, + ); + assert_eq!(ordinary.level, RiskLevel::Medium); + assert!(ordinary.auto_executable); + + for path in [ + "/etc/nginx/nginx.conf", + "/home/alice/.ssh/authorized_keys", + "/home/alice/.ssh", + "~/.ssh/config", + "/var/lib/app/state", + "/tmp/example.service", + ] { + let assessment = assess_sftp_risk(SftpRiskOperation::Write, path, None, false, None); + assert_eq!(assessment.level, RiskLevel::High, "path: {path}"); + assert!(!assessment.auto_executable); + } + + assert_eq!( + assess_sftp_risk( + SftpRiskOperation::Write, + "/home/alice/notes.txt", + None, + true, + None, + ) + .level, + RiskLevel::High + ); + assert_eq!( + assess_sftp_risk( + SftpRiskOperation::Rename, + "/home/alice/config", + Some("/etc/app.conf"), + false, + None, + ) + .level, + RiskLevel::High + ); + assert_eq!( + assess_sftp_risk( + SftpRiskOperation::Chmod, + "~/.ssh/authorized_keys", + None, + false, + Some("0777"), + ) + .level, + RiskLevel::High + ); + assert_eq!( + assess_sftp_risk( + SftpRiskOperation::Delete, + "/home/alice/notes.txt", + None, + false, + None, + ) + .level, + RiskLevel::High + ); + } +} diff --git a/src-tauri/src/core/mcp/approval.rs b/src-tauri/src/core/mcp/approval.rs index 7b659e36..cef12cad 100644 --- a/src-tauri/src/core/mcp/approval.rs +++ b/src-tauri/src/core/mcp/approval.rs @@ -36,6 +36,8 @@ pub struct ApprovalRequestEvent { pub capability: String, pub session_id: Option, pub session_name: Option, + pub connection_id: Option, + pub connection_name: Option, pub parameter_summary: String, pub risk: RiskLevel, } diff --git a/src-tauri/src/core/mcp/discovery.rs b/src-tauri/src/core/mcp/discovery.rs index 06af269e..30eadb16 100644 --- a/src-tauri/src/core/mcp/discovery.rs +++ b/src-tauri/src/core/mcp/discovery.rs @@ -117,59 +117,7 @@ fn set_private_file_permissions(path: &Path) -> AppResult<()> { #[cfg(windows)] fn set_windows_current_user_acl(path: &Path, directory: bool) -> AppResult<()> { - use std::os::windows::process::CommandExt; - - // Build a protected DACL from the current token SID instead of a user name. Starting - // from a fresh ACL also removes explicit grants that may have existed on a stale runtime - // directory, so another local account cannot inherit or retain access to the credential. - const SCRIPT: &str = r#" -$ErrorActionPreference = 'Stop' -$target = $args[0] -$isDirectory = $args[1] -eq '1' -$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User -$acl = if ($isDirectory) { - [System.Security.AccessControl.DirectorySecurity]::new() -} else { - [System.Security.AccessControl.FileSecurity]::new() -} -$acl.SetOwner($sid) -$acl.SetAccessRuleProtection($true, $false) -$inheritance = if ($isDirectory) { - [System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit -} else { - [System.Security.AccessControl.InheritanceFlags]::None -} -$rule = [System.Security.AccessControl.FileSystemAccessRule]::new( - $sid, - [System.Security.AccessControl.FileSystemRights]::FullControl, - $inheritance, - [System.Security.AccessControl.PropagationFlags]::None, - [System.Security.AccessControl.AccessControlType]::Allow -) -$acl.SetAccessRule($rule) -Set-Acl -LiteralPath $target -AclObject $acl -"#; - let output = std::process::Command::new("powershell.exe") - .args([ - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-Command", - SCRIPT, - ]) - .arg(path) - .arg(if directory { "1" } else { "0" }) - .creation_flags(0x0800_0000) - .output()?; - if output.status.success() { - Ok(()) - } else { - Err(AppError::Config( - "Failed to apply a current-user-only ACL to MCP discovery data.".into(), - )) - } + super::windows_acl::set_current_user_only(path, directory) } #[cfg(not(any(unix, windows)))] @@ -194,4 +142,52 @@ mod tests { store.remove().unwrap(); let _ = std::fs::remove_dir_all(root); } + + #[cfg(windows)] + #[test] + fn discovery_replacement_preserves_private_acl_and_removes_temporary_files() { + let root = std::env::temp_dir().join(format!( + "nyaterm-mcp-discovery-test-{}", + uuid::Uuid::new_v4() + )); + let store = DiscoveryStore::new(&root); + let first = discovery_document("first-token", "first-generation"); + let second = discovery_document("second-token", "second-generation"); + + store.write(&first).unwrap(); + store.write(&second).unwrap(); + + let actual: DiscoveryDocument = + serde_json::from_slice(&std::fs::read(&store.file).unwrap()).unwrap(); + assert_eq!(actual.token, second.token); + assert_eq!(actual.generation, second.generation); + super::super::windows_acl::assert_current_user_only(&store.directory, true); + super::super::windows_acl::assert_current_user_only(&store.file, false); + + let temporary_files = std::fs::read_dir(&store.directory) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| { + let name = entry.file_name(); + let name = name.to_string_lossy(); + name.starts_with(".discovery-") && name.ends_with(".tmp") + }) + .count(); + assert_eq!(temporary_files, 0); + + let _ = std::fs::remove_dir_all(root); + } + + #[cfg(windows)] + fn discovery_document(token: &str, generation: &str) -> DiscoveryDocument { + DiscoveryDocument { + version: 1, + pid: std::process::id(), + host: "127.0.0.1".into(), + port: 47_123, + token: token.into(), + generation: generation.into(), + permission_mode: "read-only".into(), + } + } } diff --git a/src-tauri/src/core/mcp/host.rs b/src-tauri/src/core/mcp/host.rs index b3f9326d..978d6f90 100644 --- a/src-tauri/src/core/mcp/host.rs +++ b/src-tauri/src/core/mcp/host.rs @@ -8,9 +8,9 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use nyaterm_mcp_protocol::{ AuthParams, CapabilityExecuteParams, ClientIdentifyParams, DiscoveryDocument, MAX_INLINE_OUTPUT_BYTES, MAX_RPC_LINE_BYTES, MAX_TEXT_READ_BYTES, MAX_TEXT_WRITE_BYTES, - PROTOCOL_VERSION, PathArgs, RpcError, RpcRequest, RpcResponse, SessionArgs, SftpChmodArgs, - SftpMkdirArgs, SftpReadTextArgs, SftpRenameArgs, SftpWriteTextArgs, TerminalExecuteArgs, - TerminalRecentOutputArgs, tool, + PROTOCOL_VERSION, PathArgs, RpcError, RpcRequest, RpcResponse, SessionArgs, SessionOpenArgs, + SftpChmodArgs, SftpMkdirArgs, SftpReadTextArgs, SftpRenameArgs, SftpWriteTextArgs, + TerminalExecuteArgs, TerminalRecentOutputArgs, tool, }; use rand::RngCore; use serde::Serialize; @@ -19,21 +19,22 @@ use sha2::{Digest, Sha256}; use tauri::{AppHandle, Emitter, Manager}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{TcpListener, TcpStream}; -use tokio::sync::{Mutex, RwLock}; +use tokio::sync::{Mutex, RwLock, oneshot}; use tokio_util::sync::CancellationToken; use super::approval::{ApprovalDecision, ApprovalRequestEvent, McpApprovalManager}; use super::discovery::DiscoveryStore; use crate::config::{ - AiExecutionProfile, AiPermissionMode, ExternalMcpServerMode, ExternalMcpSessionScope, + AiExecutionProfile, AiPermissionMode, ConnectionType, ExternalMcpSessionScope, ExternalMcpSettings, RiskLevel, }; use crate::core::SessionManager; use crate::core::ai::{AppendAiAuditRequest, append_ai_audit, redact_sensitive_text}; use crate::core::capabilities::sftp as sftp_capability; use crate::core::capabilities::{ - CapabilityAccess, McpScope, OutputStore, PolicyDecision, TerminalExecuteRequest, - assess_command_risk, capability_for_tool, decide_policy, execute_terminal_command, + CapabilityAccess, McpScope, McpScopeSnapshot, OutputStore, PolicyDecision, RiskAssessment, + TerminalExecuteRequest, assess_command_risk, capability_for_tool, decide_policy, + execute_terminal_command, }; use crate::core::session::{SessionInfo, SessionType}; use crate::error::{AppError, AppResult}; @@ -63,6 +64,30 @@ pub struct McpClientConfigs { pub cursor: Value, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct McpConnectionSummary { + id: String, + name: String, + r#type: String, + group_path: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct McpSessionOpenRequestEvent { + request_id: String, + connection_id: String, + target_window_label: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct McpSessionOpenCancelEvent { + request_id: String, + target_window_label: String, +} + #[derive(Clone)] pub struct EphemeralMcpCredential { pub host: String, @@ -110,16 +135,20 @@ struct Credential { source: String, owner_window_label: Option, cancellation: CancellationToken, + opened_session_ids: RwLock>, } struct ExternalRuntime { settings: ExternalMcpSettings, owner_window_label: String, generation: String, - scoped_session_count: usize, + scope: Arc, cancellation: CancellationToken, - last_activity: Arc>, - approval_waiters: Arc, +} + +struct PendingSessionOpen { + owner_window_label: String, + responder: oneshot::Sender>, } struct ConnectionContext { @@ -142,10 +171,12 @@ pub struct McpManager { shutdown: CancellationToken, credentials: RwLock>>, external: Mutex>, - persistent_startup: Mutex>, + startup_settings: Mutex>, approvals: Arc, request_cancellations: Mutex>, + active_sessions: RwLock>, external_connections: AtomicUsize, + pending_session_opens: Mutex>, last_error: StdMutex>, } @@ -165,10 +196,12 @@ impl McpManager { shutdown: CancellationToken::new(), credentials: RwLock::new(HashMap::new()), external: Mutex::new(None), - persistent_startup: Mutex::new(None), + startup_settings: Mutex::new(None), approvals: Arc::new(McpApprovalManager::default()), request_cancellations: Mutex::new(HashMap::new()), + active_sessions: RwLock::new(HashMap::new()), external_connections: AtomicUsize::new(0), + pending_session_opens: Mutex::new(HashMap::new()), last_error: StdMutex::new(None), }) } @@ -186,16 +219,8 @@ impl McpManager { tauri::async_runtime::spawn(async move { manager.accept_loop(listener).await }); let settings = crate::config::load_app_settings(&app)?.ai.external_mcp; - if settings.enabled && settings.server_mode == ExternalMcpServerMode::Persistent { - *self.persistent_startup.lock().await = Some(settings); - } else if settings.enabled { - let _ = crate::storage::update_settings_doc( - crate::storage::SettingsDocKey::AppSettings, - |stored: &mut crate::config::AppSettings| { - stored.ai.external_mcp.enabled = false; - Ok(()) - }, - ); + if settings.enabled { + *self.startup_settings.lock().await = Some(settings); } Ok(()) } @@ -204,7 +229,7 @@ impl McpManager { self: &Arc, owner_window_label: &str, ) -> AppResult { - let settings = self.persistent_startup.lock().await.take(); + let settings = self.startup_settings.lock().await.take(); if let Some(settings) = settings { self.configure_external(settings, owner_window_label).await } else { @@ -222,11 +247,6 @@ impl McpManager { settings: ExternalMcpSettings, owner_window_label: &str, ) -> AppResult { - if !(1..=120).contains(&settings.idle_timeout_minutes) { - return Err(AppError::Config( - "External MCP idle timeout must be between 1 and 120 minutes.".into(), - )); - } if !settings.enabled { self.disable_external(false).await?; return Ok(self.status().await); @@ -236,56 +256,47 @@ impl McpManager { "The MCP bridge is not initialized.".into(), )); } - { + let unchanged = { let current = self.external.lock().await; if let Some(current) = current.as_ref() { if current.owner_window_label != owner_window_label { return Err(AppError::Config("External MCP is already bound to another NyaTerm window. Disable it before enabling it from this window.".into())); } - if current.settings == settings { - return Ok(self.status_from(Some(current))); - } + current.settings == settings + } else { + false } + }; + if unchanged { + return Ok(self.status().await); } self.disable_external(false).await?; - let mut session_ids = self - .sessions - .list_sessions() - .await - .into_iter() - .filter(|session| { - settings.session_scope == ExternalMcpSessionScope::AllSessions - || session.owner_window_label.as_deref() == Some(owner_window_label) - }) - .map(|session| session.id) - .collect::>(); - session_ids.sort(); - let default_session_id = (session_ids.len() == 1).then(|| session_ids[0].clone()); + let scope = Arc::new(match settings.session_scope { + ExternalMcpSessionScope::CurrentWindow => McpScope::current_window(owner_window_label), + ExternalMcpSessionScope::AllSessions => McpScope::AllSessions, + }); let generation = uuid::Uuid::new_v4().to_string(); let token = random_token(); let cancellation = CancellationToken::new(); let credential = Arc::new(Credential { token: token.clone(), - scope: Arc::new(McpScope::new(session_ids.clone(), default_session_id)), + scope: scope.clone(), permission_mode: settings.permission_mode.clone(), source: EXTERNAL_SOURCE.into(), owner_window_label: Some(owner_window_label.to_string()), cancellation: cancellation.clone(), + opened_session_ids: RwLock::new(HashSet::new()), }); self.credentials .write() .await .insert(generation.clone(), credential); - let last_activity = Arc::new(StdMutex::new(Instant::now())); - let approval_waiters = Arc::new(AtomicUsize::new(0)); *self.external.lock().await = Some(ExternalRuntime { settings: settings.clone(), owner_window_label: owner_window_label.to_string(), generation: generation.clone(), - scoped_session_count: session_ids.len(), + scope, cancellation: cancellation.clone(), - last_activity: last_activity.clone(), - approval_waiters: approval_waiters.clone(), }); let document = DiscoveryDocument { version: PROTOCOL_VERSION, @@ -302,20 +313,6 @@ impl McpManager { return Err(error); } self.set_error(None); - if settings.server_mode == ExternalMcpServerMode::Temporary { - let manager = self.clone(); - tauri::async_runtime::spawn(async move { - manager - .temporary_idle_worker( - generation, - settings.idle_timeout_minutes, - last_activity, - approval_waiters, - cancellation, - ) - .await; - }); - } self.emit_status(); Ok(self.status().await) } @@ -342,15 +339,31 @@ impl McpManager { Ok(()) } - pub async fn owner_window_closed(&self, label: &str) { - let matches = self + pub async fn owner_window_closed(self: &Arc, label: &str) { + self.active_sessions.write().await.remove(label); + let settings = self .external .lock() .await .as_ref() - .is_some_and(|state| state.owner_window_label == label); - if matches { - let _ = self.disable_external(true).await; + .filter(|state| state.owner_window_label == label) + .map(|state| state.settings.clone()); + let Some(settings) = settings else { return }; + + let replacement = self.app.get().and_then(|app| { + let mut windows = crate::app::main_windows(app) + .into_iter() + .filter(|window| window.label() != label) + .collect::>(); + windows.sort_by(|left, right| left.label().cmp(right.label())); + windows.first().map(|window| window.label().to_string()) + }); + let _ = self.disable_external(false).await; + if let Some(replacement) = replacement { + if let Err(error) = self.configure_external(settings, &replacement).await { + self.set_error(Some(error.to_string())); + self.emit_status(); + } } } @@ -366,20 +379,32 @@ impl McpManager { pub async fn status(&self) -> McpRuntimeStatus { let external = self.external.lock().await; - self.status_from(external.as_ref()) - } - - fn status_from(&self, external: Option<&ExternalRuntime>) -> McpRuntimeStatus { + let metadata = external.as_ref().map(|state| { + ( + state.owner_window_label.clone(), + state.generation.clone(), + state.scope.clone(), + ) + }); + drop(external); + let scoped_session_count = if let Some((_, _, scope)) = metadata.as_ref() { + scope + .resolve(&self.sessions.list_sessions().await) + .session_ids + .len() + } else { + 0 + }; let port = self.port.load(Ordering::SeqCst); McpRuntimeStatus { - enabled: external.is_some(), - running: external.is_some() && port != 0, + enabled: metadata.is_some(), + running: metadata.is_some() && port != 0, error: self.last_error.lock().unwrap().clone(), - owner_window_label: external.map(|state| state.owner_window_label.clone()), - scoped_session_count: external.map_or(0, |state| state.scoped_session_count), + owner_window_label: metadata.as_ref().map(|state| state.0.clone()), + scoped_session_count, connection_count: self.external_connections.load(Ordering::SeqCst), port: (port != 0).then_some(port), - generation: external.map(|state| state.generation.clone()), + generation: metadata.map(|state| state.1), } } @@ -395,6 +420,157 @@ impl McpManager { }) } + fn connection_summaries(&self) -> AppResult> { + let app = self + .app + .get() + .ok_or_else(|| AppError::Config("NyaTerm is not ready.".into()))?; + let config = crate::config::load_config(app)?; + Ok(connection_summaries_from_config(&config)) + } + + fn connection_summary(&self, connection_id: &str) -> AppResult { + if connection_id.trim().is_empty() { + return Err(AppError::Config("connectionId is required.".into())); + } + self.connection_summaries()? + .into_iter() + .find(|connection| connection.id == connection_id) + .ok_or_else(|| { + AppError::Config( + "The saved connection does not exist or is not a supported terminal connection." + .into(), + ) + }) + } + + pub async fn respond_session_open( + &self, + owner_window_label: &str, + request_id: &str, + session_id: Option, + error: Option, + ) -> AppResult<()> { + let pending = self + .pending_session_opens + .lock() + .await + .remove(request_id) + .ok_or_else(|| { + AppError::Config("The MCP session-open request is no longer pending.".into()) + })?; + if pending.owner_window_label != owner_window_label { + self.pending_session_opens + .lock() + .await + .insert(request_id.to_string(), pending); + return Err(AppError::Config( + "Only the target main window can complete this MCP session-open request.".into(), + )); + } + let result = match (session_id, error) { + (Some(session_id), None) if !session_id.trim().is_empty() => Ok(session_id), + (None, Some(error)) if !error.trim().is_empty() => Err(error), + _ => Err("Invalid MCP session-open response.".into()), + }; + pending + .responder + .send(result) + .map_err(|_| AppError::Cancelled("The MCP session-open request was cancelled.".into())) + } + + async fn request_session_open( + &self, + context: &ConnectionContext, + connection: &McpConnectionSummary, + cancellation: &CancellationToken, + ) -> AppResult { + let owner = context + .credential + .owner_window_label + .as_deref() + .ok_or_else(|| AppError::Config("The MCP owner window is unavailable.".into()))?; + let app = self + .app + .get() + .ok_or_else(|| AppError::Config("NyaTerm is not ready.".into()))?; + let window = app + .get_webview_window(owner) + .ok_or_else(|| AppError::Config("The MCP owner window is unavailable.".into()))?; + let request_id = uuid::Uuid::new_v4().to_string(); + let (tx, rx) = oneshot::channel(); + self.pending_session_opens.lock().await.insert( + request_id.clone(), + PendingSessionOpen { + owner_window_label: owner.to_string(), + responder: tx, + }, + ); + let event = McpSessionOpenRequestEvent { + request_id: request_id.clone(), + connection_id: connection.id.clone(), + target_window_label: owner.to_string(), + }; + if let Err(error) = window.emit("mcp-session-open-request", event) { + self.pending_session_opens.lock().await.remove(&request_id); + return Err(AppError::Config(format!( + "Failed to send the MCP session-open request: {error}" + ))); + } + + let result = tokio::select! { + _ = cancellation.cancelled() => { + self.pending_session_opens.lock().await.remove(&request_id); + let _ = window.emit("mcp-session-open-cancel", McpSessionOpenCancelEvent { + request_id: request_id.clone(), + target_window_label: owner.to_string(), + }); + return Err(AppError::Cancelled("The MCP session-open request was cancelled.".into())); + } + result = rx => result.map_err(|_| AppError::Cancelled("The MCP session-open request was cancelled.".into()))?, + }; + let session_id = result.map_err(AppError::Config)?; + let info = self.sessions.session_info(&session_id).await?; + if info.owner_window_label.as_deref() != Some(owner) + || info.connection_id.as_deref() != Some(connection.id.as_str()) + || !info.connected + || !matches!( + info.session_type, + SessionType::SSH | SessionType::Local | SessionType::Telnet | SessionType::Serial + ) + { + return Err(AppError::Config( + "The opened session does not match the requested saved connection.".into(), + )); + } + context + .credential + .opened_session_ids + .write() + .await + .insert(session_id.clone()); + Ok(session_id) + } + + async fn resolve_credential_scope(&self, credential: &Credential) -> McpScopeSnapshot { + let sessions = self.sessions.list_sessions().await; + let live_ids = sessions + .iter() + .map(|session| session.id.as_str()) + .collect::>(); + let mut scope = credential.scope.resolve(&sessions); + scope.session_ids.extend( + credential + .opened_session_ids + .read() + .await + .iter() + .filter(|id| live_ids.contains(id.as_str())) + .cloned(), + ); + scope + } + pub async fn create_ephemeral_credential( self: &Arc, source: &str, @@ -417,11 +593,12 @@ impl McpManager { generation.clone(), Arc::new(Credential { token: token.clone(), - scope: Arc::new(McpScope::new(session_ids, default_session_id)), + scope: Arc::new(McpScope::explicit(session_ids, default_session_id)), permission_mode, source: source.to_string(), owner_window_label, cancellation: cancellation.clone(), + opened_session_ids: RwLock::new(HashSet::new()), }), ); Ok(EphemeralMcpCredential { @@ -443,6 +620,31 @@ impl McpManager { self.approvals.respond(request_id, decision).await } + pub async fn set_active_session( + &self, + owner_window_label: &str, + session_id: Option, + ) -> AppResult<()> { + if let Some(session_id) = session_id { + let info = self.sessions.session_info(&session_id).await?; + if info.owner_window_label.as_deref() != Some(owner_window_label) { + return Err(AppError::Config( + "The active session does not belong to the reporting window.".into(), + )); + } + self.active_sessions + .write() + .await + .insert(owner_window_label.to_string(), session_id); + } else { + self.active_sessions + .write() + .await + .remove(owner_window_label); + } + Ok(()) + } + pub async fn cancel_pending_approvals(&self) { self.approvals.cancel_all().await; } @@ -546,7 +748,6 @@ impl McpManager { } if is_external { self.external_connections.fetch_add(1, Ordering::SeqCst); - self.touch_external(&context.generation).await; self.emit_status(); } loop { @@ -558,9 +759,6 @@ impl McpManager { Ok(value) => value, Err(_) => break, }; - if is_external { - self.touch_external(&context.generation).await; - } let response = self.handle_request(&context, request).await; if write_response(&mut write, response).await.is_err() { break; @@ -629,9 +827,6 @@ impl McpManager { let result = self .execute_tool(context, ¶ms.tool, params.arguments, token) .await; - if context.credential.source == EXTERNAL_SOURCE { - self.touch_external(&context.generation).await; - } if let Some(id) = params.request_id.as_ref() { self.request_cancellations.lock().await.remove(id); } @@ -678,7 +873,7 @@ impl McpManager { Ok(value) => { self.audit( context, - definition.id, + definition.capability, None, None, Some("inherited"), @@ -693,7 +888,7 @@ impl McpManager { Err(error) => { self.audit( context, - definition.id, + definition.capability, None, None, Some("inherited"), @@ -707,13 +902,23 @@ impl McpManager { } } } - let session_id = match self.resolve_session(context, tool_name, &arguments) { + let scope = self.resolve_credential_scope(&context.credential).await; + let connection_target = if tool_name == tool::SESSION_OPEN { + let args = parse::(arguments.clone())?; + Some( + self.connection_summary(&args.connection_id) + .map_err(map_error)?, + ) + } else { + None + }; + let session_id = match Self::resolve_session(&scope, tool_name, &arguments) { Ok(session_id) => session_id, Err(error) => { let mapped = map_error(error); self.audit( context, - definition.id, + definition.capability, arguments.get("sessionId").and_then(Value::as_str), None, Some("validation_denied"), @@ -732,22 +937,85 @@ impl McpManager { "A target session is required for this capability.", )); } - let risk = if tool_name == tool::TERMINAL_EXECUTE { - Some( - assess_command_risk(&parse::(arguments.clone())?.command) - .level, - ) - } else { - None + let assessment: Option = match tool_name { + tool::TERMINAL_EXECUTE => Some(assess_command_risk( + &parse::(arguments.clone())?.command, + )), + tool::SFTP_WRITE_TEXT => { + let args = parse::(arguments.clone())?; + Some(sftp_capability::assess_sftp_risk( + sftp_capability::SftpRiskOperation::Write, + &args.path, + None, + args.force.unwrap_or(false), + None, + )) + } + tool::SFTP_MKDIR => { + let args = parse::(arguments.clone())?; + Some(sftp_capability::assess_sftp_risk( + sftp_capability::SftpRiskOperation::Mkdir, + &args.path, + None, + false, + args.mode.as_deref(), + )) + } + tool::SFTP_RENAME => { + let args = parse::(arguments.clone())?; + Some(sftp_capability::assess_sftp_risk( + sftp_capability::SftpRiskOperation::Rename, + &args.old_path, + Some(&args.new_path), + false, + None, + )) + } + tool::SFTP_DELETE => { + let args = parse::(arguments.clone())?; + Some(sftp_capability::assess_sftp_risk( + sftp_capability::SftpRiskOperation::Delete, + &args.path, + None, + false, + None, + )) + } + tool::SFTP_CHMOD => { + let args = parse::(arguments.clone())?; + Some(sftp_capability::assess_sftp_risk( + sftp_capability::SftpRiskOperation::Chmod, + &args.path, + None, + false, + Some(&args.mode), + )) + } + _ => None, }; let policy = decide_policy( &context.credential.permission_mode, definition.access, - risk.as_ref(), + assessment.as_ref(), ); - let grant_key = session_id.clone().map(|id| (id, definition.id.to_string())); + let risk = assessment.as_ref().map(|value| value.level.clone()); + let grant_key = connection_target + .as_ref() + .map(|connection| { + ( + format!("connection:{}", connection.id), + definition.capability.to_string(), + ) + }) + .or_else(|| { + session_id + .clone() + .map(|id| (id, definition.capability.to_string())) + }); let grantable = definition.access != CapabilityAccess::DestructiveWrite - && risk.as_ref().is_none_or(|value| *value < RiskLevel::High); + && assessment + .as_ref() + .is_none_or(|value| value.auto_executable && value.level < RiskLevel::High); let granted = grantable && match grant_key.as_ref() { Some(key) => context.grants.lock().await.contains(key), @@ -757,7 +1025,7 @@ impl McpManager { if policy == PolicyDecision::Deny { self.audit( context, - definition.id, + definition.capability, session_id.as_deref(), risk, Some("policy_denied"), @@ -773,37 +1041,34 @@ impl McpManager { )); } if policy == PolicyDecision::RequireApproval && !granted { - let target = session_id.as_deref().ok_or_else(|| { - failure( - "approval_denied", - "A target session is required for approval.", + let info = if let Some(target) = session_id.as_deref() { + Some( + self.sessions + .session_info(target) + .await + .map_err(map_error)?, ) - })?; - let info = self - .sessions - .session_info(target) - .await - .map_err(map_error)?; + } else { + None + }; let owner = info - .owner_window_label - .as_deref() + .as_ref() + .and_then(|info| info.owner_window_label.as_deref()) .or(context.credential.owner_window_label.as_deref()) .ok_or_else(|| { failure( "approval_denied", - "The session owner window is unavailable for approval.", + "The MCP owner window is unavailable for approval.", ) })?; - let waiter = self.external_waiter(&context.generation).await; - if let Some(waiter) = waiter.as_ref() { - waiter.fetch_add(1, Ordering::SeqCst); - } let event = ApprovalRequestEvent { request_id: uuid::Uuid::new_v4().to_string(), client: context.client.lock().unwrap().clone(), - capability: definition.id.to_string(), - session_id: Some(target.to_string()), - session_name: Some(info.name), + capability: definition.capability.to_string(), + session_id: session_id.clone(), + session_name: info.as_ref().map(|info| info.name.clone()), + connection_id: connection_target.as_ref().map(|value| value.id.clone()), + connection_name: connection_target.as_ref().map(|value| value.name.clone()), parameter_summary: summarize(tool_name, &arguments), risk: risk .clone() @@ -821,16 +1086,13 @@ impl McpManager { &cancellation, ) .await; - if let Some(waiter) = waiter.as_ref() { - waiter.fetch_sub(1, Ordering::SeqCst); - } let decision = match result { Ok(decision) => decision, Err(error) => { self.audit( context, - definition.id, - Some(target), + definition.capability, + session_id.as_deref(), risk.clone(), Some("approval_unavailable"), false, @@ -846,8 +1108,8 @@ impl McpManager { if decision == ApprovalDecision::Deny { self.audit( context, - definition.id, - Some(target), + definition.capability, + session_id.as_deref(), risk, approval, false, @@ -861,28 +1123,42 @@ impl McpManager { "The operation was denied by the user.", )); } - if decision == ApprovalDecision::AllowSession && grantable { - if let Some(key) = grant_key { - context.grants.lock().await.insert(key); - } + if decision == ApprovalDecision::AllowSession + && grantable + && let Some(key) = grant_key + { + context.grants.lock().await.insert(key); } } - let result = tokio::select! { - _ = cancellation.cancelled() => Err(failure("cancelled", "The MCP request was cancelled.")), - value = self.dispatch( + let result = if tool_name == tool::SESSION_OPEN { + self.dispatch( context, + &scope, tool_name, arguments.clone(), session_id.as_deref(), cancellation.clone(), - ) => value, + ) + .await + } else { + tokio::select! { + _ = cancellation.cancelled() => Err(failure("cancelled", "The MCP request was cancelled.")), + value = self.dispatch( + context, + &scope, + tool_name, + arguments.clone(), + session_id.as_deref(), + cancellation.clone(), + ) => value, + } }; let elapsed = started.elapsed(); match result { Ok(value) => { self.audit( context, - definition.id, + definition.capability, session_id.as_deref(), risk, approval, @@ -897,7 +1173,7 @@ impl McpManager { Err(error) => { self.audit( context, - definition.id, + definition.capability, session_id.as_deref(), risk, approval, @@ -913,19 +1189,19 @@ impl McpManager { } fn resolve_session( - &self, - context: &ConnectionContext, + scope: &McpScopeSnapshot, tool_name: &str, arguments: &Value, ) -> AppResult> { - if tool_name == tool::GET_ENVIRONMENT { + if matches!( + tool_name, + tool::GET_ENVIRONMENT | tool::CONNECTION_LIST | tool::SESSION_OPEN + ) { return Ok(None); } if tool_name == tool::TERMINAL_EXECUTE { let args: TerminalExecuteArgs = serde_json::from_value(arguments.clone())?; - return context - .credential - .scope + return scope .resolve_terminal_session(args.session_id.as_deref()) .map(Some); } @@ -933,13 +1209,14 @@ impl McpManager { .get("sessionId") .and_then(Value::as_str) .ok_or_else(|| AppError::Config("sessionId is required.".into()))?; - context.credential.scope.require(id)?; + scope.require(id)?; Ok(Some(id.to_string())) } async fn dispatch( &self, context: &ConnectionContext, + scope: &McpScopeSnapshot, name: &str, arguments: Value, session_id: Option<&str>, @@ -948,15 +1225,49 @@ impl McpManager { match name { tool::GET_ENVIRONMENT => { let mut sessions = Vec::new(); - for id in &context.credential.scope.session_ids { + for id in &scope.session_ids { if let Ok(info) = self.sessions.session_info(id).await { sessions.push(safe_metadata(&info)); } } sessions.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); - Ok( - json!({ "defaultSessionId": context.credential.scope.default_session_id, "sessions": sessions }), - ) + let active_session_id = + if let Some(owner) = context.credential.owner_window_label.as_deref() { + let active_sessions = self.active_sessions.read().await; + scoped_active_session_id(active_sessions.get(owner), scope) + } else { + None + }; + Ok(json!({ + "activeSessionId": active_session_id, + "defaultSessionId": scope.default_session_id, + "sessions": sessions, + })) + } + tool::CONNECTION_LIST => Ok(json!({ + "connections": self.connection_summaries().map_err(map_error)?, + })), + tool::SESSION_OPEN => { + let args: SessionOpenArgs = parse(arguments)?; + let connection = self + .connection_summary(&args.connection_id) + .map_err(map_error)?; + let session_id = self + .request_session_open(context, &connection, &cancellation) + .await + .map_err(map_error)?; + let info = self + .sessions + .session_info(&session_id) + .await + .map_err(map_error)?; + Ok(json!({ + "sessionId": session_id, + "connectionId": connection.id, + "name": info.name, + "type": session_type_name(&info.session_type), + "connected": true, + })) } tool::SESSION_GET => { let args: SessionArgs = parse(arguments)?; @@ -1144,52 +1455,6 @@ impl McpManager { .then_some(credential) } - async fn temporary_idle_worker( - self: Arc, - generation: String, - minutes: u16, - last_activity: Arc>, - approval_waiters: Arc, - cancellation: CancellationToken, - ) { - let timeout = Duration::from_secs(u64::from(minutes) * 60); - loop { - tokio::select! { _ = cancellation.cancelled() => return, _ = tokio::time::sleep(Duration::from_secs(5)) => {} } - if approval_waiters.load(Ordering::SeqCst) > 0 { - continue; - } - if last_activity.lock().unwrap().elapsed() >= timeout { - if self - .external - .lock() - .await - .as_ref() - .is_some_and(|state| state.generation == generation) - { - let _ = self.disable_external(true).await; - } - return; - } - } - } - - async fn touch_external(&self, generation: &str) { - if let Some(state) = self.external.lock().await.as_ref() { - if state.generation == generation { - *state.last_activity.lock().unwrap() = Instant::now(); - } - } - } - - async fn external_waiter(&self, generation: &str) -> Option> { - self.external - .lock() - .await - .as_ref() - .filter(|state| state.generation == generation) - .map(|state| state.approval_waiters.clone()) - } - #[allow(clippy::too_many_arguments)] fn audit( &self, @@ -1277,6 +1542,7 @@ fn permission_mode_name(value: &AiPermissionMode) -> &'static str { AiPermissionMode::Observer => "observer", AiPermissionMode::Confirm => "confirm", AiPermissionMode::Auto => "auto", + AiPermissionMode::FullAccess => "full_access", } } fn session_type_name(value: &SessionType) -> &'static str { @@ -1287,6 +1553,61 @@ fn session_type_name(value: &SessionType) -> &'static str { SessionType::Serial => "serial", } } +fn terminal_connection_type(value: &ConnectionType) -> Option<&'static str> { + match value { + ConnectionType::Ssh { .. } => Some("ssh"), + ConnectionType::LocalTerminal { .. } => Some("local_terminal"), + ConnectionType::Telnet { .. } => Some("telnet"), + ConnectionType::Serial { .. } => Some("serial"), + ConnectionType::Rdp { .. } | ConnectionType::Vnc { .. } => None, + } +} +fn connection_summaries_from_config( + config: &crate::config::AppConfig, +) -> Vec { + let groups = config + .groups + .iter() + .map(|group| (group.id.as_str(), group)) + .collect::>(); + let mut connections = config + .connections + .iter() + .filter_map(|connection| { + terminal_connection_type(&connection.config).map(|kind| McpConnectionSummary { + id: connection.id.clone(), + name: connection.name.clone(), + r#type: kind.to_string(), + group_path: connection_group_path(connection.group_id.as_deref(), &groups), + }) + }) + .collect::>(); + connections.sort_by(|left, right| { + left.group_path + .cmp(&right.group_path) + .then_with(|| left.name.cmp(&right.name)) + .then_with(|| left.id.cmp(&right.id)) + }); + connections +} +fn connection_group_path( + group_id: Option<&str>, + groups: &HashMap<&str, &crate::config::Group>, +) -> Vec { + let mut path = Vec::new(); + let mut visited = HashSet::new(); + let mut current = group_id; + while let Some(id) = current { + if !visited.insert(id.to_string()) { + break; + } + let Some(group) = groups.get(id) else { break }; + path.push(group.name.clone()); + current = group.parent_id.as_deref(); + } + path.reverse(); + path +} fn execution_profile_name(value: AiExecutionProfile) -> &'static str { match value { AiExecutionProfile::Disabled => "disabled", @@ -1300,6 +1621,14 @@ fn sftp_available(info: &SessionInfo) -> bool { fn safe_metadata(info: &SessionInfo) -> Value { json!({ "id": info.id, "name": info.name, "type": session_type_name(&info.session_type), "connected": info.connected }) } +fn scoped_active_session_id( + active_session_id: Option<&String>, + scope: &McpScopeSnapshot, +) -> Option { + active_session_id + .filter(|id| scope.session_ids.contains(*id)) + .cloned() +} fn access_risk(value: CapabilityAccess) -> RiskLevel { match value { CapabilityAccess::Read => RiskLevel::Low, @@ -1309,6 +1638,12 @@ fn access_risk(value: CapabilityAccess) -> RiskLevel { } fn summarize(name: &str, args: &Value) -> String { let value = match name { + tool::SESSION_OPEN => format!( + "connectionId={}", + args.get("connectionId") + .and_then(Value::as_str) + .unwrap_or_default() + ), tool::TERMINAL_EXECUTE => args .get("command") .and_then(Value::as_str) @@ -1432,6 +1767,70 @@ mod tests { assert_eq!(URL_SAFE_NO_PAD.decode(random_token()).unwrap().len(), 32); } + #[test] + fn full_access_permission_name_is_written_to_metadata() { + assert_eq!( + permission_mode_name(&AiPermissionMode::FullAccess), + "full_access" + ); + } + + #[test] + fn connection_summaries_filter_graphical_connections_and_secrets() { + let config: crate::config::AppConfig = serde_json::from_value(json!({ + "groups": [ + { "id": "root", "name": "Production" }, + { "id": "child", "name": "Linux", "parent_id": "root" } + ], + "connections": [ + { + "id": "ssh-1", + "name": "Web server", + "type": "ssh", + "host": "secret.example.com", + "username": "root", + "group_id": "child" + }, + { + "id": "rdp-1", + "name": "Desktop", + "type": "rdp", + "host": "desktop.example.com" + } + ] + })) + .expect("saved connections"); + + let summaries = connection_summaries_from_config(&config); + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].id, "ssh-1"); + assert_eq!(summaries[0].r#type, "ssh"); + assert_eq!(summaries[0].group_path, ["Production", "Linux"]); + let serialized = serde_json::to_string(&summaries).unwrap(); + assert!(!serialized.contains("secret.example.com")); + assert!(!serialized.contains("username")); + assert!(!serialized.contains("rdp-1")); + } + + #[test] + fn active_session_must_be_live_and_scoped() { + let scope = McpScopeSnapshot { + session_ids: HashSet::from(["session-a".into()]), + default_session_id: None, + }; + assert_eq!( + scoped_active_session_id(Some(&"session-a".into()), &scope).as_deref(), + Some("session-a") + ); + assert!(scoped_active_session_id(Some(&"session-b".into()), &scope).is_none()); + + let closed_scope = McpScopeSnapshot { + session_ids: HashSet::new(), + default_session_id: None, + }; + assert!(scoped_active_session_id(Some(&"session-a".into()), &closed_scope).is_none()); + } + #[tokio::test] async fn rpc_reader_requires_a_newline_and_enforces_the_limit() { let (mut writer, reader) = tokio::io::duplex(MAX_RPC_LINE_BYTES + 16); diff --git a/src-tauri/src/core/mcp/mod.rs b/src-tauri/src/core/mcp/mod.rs index 1ed63650..85f51103 100644 --- a/src-tauri/src/core/mcp/mod.rs +++ b/src-tauri/src/core/mcp/mod.rs @@ -1,6 +1,8 @@ mod approval; mod discovery; mod host; +#[cfg(windows)] +mod windows_acl; pub use approval::ApprovalDecision; pub use host::{EphemeralMcpCredential, McpClientConfigs, McpManager, McpRuntimeStatus}; diff --git a/src-tauri/src/core/mcp/windows_acl.rs b/src-tauri/src/core/mcp/windows_acl.rs new file mode 100644 index 00000000..e78cc521 --- /dev/null +++ b/src-tauri/src/core/mcp/windows_acl.rs @@ -0,0 +1,315 @@ +use std::ffi::c_void; +use std::mem::size_of; +use std::os::windows::ffi::OsStrExt; +use std::path::Path; +use std::ptr::{null, null_mut}; + +use windows_sys::Win32::Foundation::{ + CloseHandle, ERROR_INSUFFICIENT_BUFFER, ERROR_SUCCESS, GetLastError, HANDLE, LocalFree, +}; +use windows_sys::Win32::Security::Authorization::{ + EXPLICIT_ACCESS_W, NO_MULTIPLE_TRUSTEE, SE_FILE_OBJECT, SET_ACCESS, SetEntriesInAclW, + SetNamedSecurityInfoW, TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W, +}; +use windows_sys::Win32::Security::{ + ACL, CONTAINER_INHERIT_ACE, DACL_SECURITY_INFORMATION, GetTokenInformation, IsValidSid, + NO_INHERITANCE, OBJECT_INHERIT_ACE, OWNER_SECURITY_INFORMATION, + PROTECTED_DACL_SECURITY_INFORMATION, PSID, TOKEN_QUERY, TOKEN_USER, TokenUser, +}; +use windows_sys::Win32::Storage::FileSystem::FILE_ALL_ACCESS; +use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + +use crate::error::{AppError, AppResult}; + +pub(super) fn set_current_user_only(path: &Path, directory: bool) -> AppResult<()> { + let user = current_user_sid(path)?; + let acl = create_private_acl(path, user.sid(), directory)?; + let path_wide = wide_path(path)?; + let security_information = OWNER_SECURITY_INFORMATION + | DACL_SECURITY_INFORMATION + | PROTECTED_DACL_SECURITY_INFORMATION; + let status = unsafe { + SetNamedSecurityInfoW( + path_wide.as_ptr(), + SE_FILE_OBJECT, + security_information, + user.sid(), + null_mut(), + acl.as_ptr(), + null(), + ) + }; + if status != ERROR_SUCCESS { + return Err(status_error("SetNamedSecurityInfoW", path, status)); + } + Ok(()) +} + +fn current_user_sid(path: &Path) -> AppResult { + let mut raw_token: HANDLE = null_mut(); + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut raw_token) } == 0 { + return Err(last_error("OpenProcessToken", path)); + } + let token = OwnedHandle(raw_token); + + let mut required_length = 0; + let initial_result = + unsafe { GetTokenInformation(token.0, TokenUser, null_mut(), 0, &mut required_length) }; + if initial_result != 0 { + return Err(AppError::Config(format!( + "Failed to query the current user SID for MCP discovery data during \ + GetTokenInformation(size) for '{}': unexpected result", + path.display() + ))); + } + let error = unsafe { GetLastError() }; + if error != ERROR_INSUFFICIENT_BUFFER || required_length == 0 { + return Err(status_error("GetTokenInformation(size)", path, error)); + } + + // TOKEN_USER contains pointer-sized fields. A usize buffer keeps the allocation aligned + // while still providing the variable-length storage required for the trailing SID. + let word_count = (required_length as usize).div_ceil(size_of::()); + let mut buffer = vec![0usize; word_count]; + let buffer_length = required_length; + if unsafe { + GetTokenInformation( + token.0, + TokenUser, + buffer.as_mut_ptr().cast(), + buffer_length, + &mut required_length, + ) + } == 0 + { + return Err(last_error("GetTokenInformation(TokenUser)", path)); + } + + let token_user = unsafe { &*buffer.as_ptr().cast::() }; + let sid = token_user.User.Sid; + if sid.is_null() || unsafe { IsValidSid(sid) } == 0 { + return Err(AppError::Config(format!( + "Failed to query the current user SID for MCP discovery data during \ + GetTokenInformation(TokenUser) for '{}': Windows returned an invalid SID", + path.display() + ))); + } + + Ok(CurrentUserSid { + _buffer: buffer, + sid, + }) +} + +fn create_private_acl(path: &Path, sid: PSID, directory: bool) -> AppResult> { + let inheritance = if directory { + OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE + } else { + NO_INHERITANCE + }; + let trustee = TRUSTEE_W { + pMultipleTrustee: null_mut(), + MultipleTrusteeOperation: NO_MULTIPLE_TRUSTEE, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_USER, + ptstrName: sid.cast(), + }; + let access = EXPLICIT_ACCESS_W { + grfAccessPermissions: FILE_ALL_ACCESS, + grfAccessMode: SET_ACCESS, + grfInheritance: inheritance, + Trustee: trustee, + }; + let mut raw_acl: *mut ACL = null_mut(); + let status = unsafe { SetEntriesInAclW(1, &access, null(), &mut raw_acl) }; + if status != ERROR_SUCCESS { + if !raw_acl.is_null() { + drop(LocalAllocation(raw_acl)); + } + return Err(status_error("SetEntriesInAclW", path, status)); + } + if raw_acl.is_null() { + return Err(AppError::Config(format!( + "Failed to build a current-user-only ACL for MCP discovery data during \ + SetEntriesInAclW for '{}': Windows returned a null ACL", + path.display() + ))); + } + Ok(LocalAllocation(raw_acl)) +} + +fn wide_path(path: &Path) -> AppResult> { + let mut wide = path.as_os_str().encode_wide().collect::>(); + if wide.contains(&0) { + return Err(AppError::Config(format!( + "Failed to apply a current-user-only ACL to MCP discovery data: path contains an \ + embedded NUL: '{}'", + path.display() + ))); + } + wide.push(0); + Ok(wide) +} + +fn last_error(operation: &str, path: &Path) -> AppError { + status_error(operation, path, unsafe { GetLastError() }) +} + +fn status_error(operation: &str, path: &Path, status: u32) -> AppError { + let source = std::io::Error::from_raw_os_error(status as i32); + AppError::Config(format!( + "Failed to apply a current-user-only ACL to MCP discovery data during {operation} for \ + '{}': win32_error={status} ({source})", + path.display() + )) +} + +struct CurrentUserSid { + _buffer: Vec, + sid: PSID, +} + +impl CurrentUserSid { + const fn sid(&self) -> PSID { + self.sid + } +} + +struct OwnedHandle(HANDLE); + +impl Drop for OwnedHandle { + fn drop(&mut self) { + if !self.0.is_null() { + let _ = unsafe { CloseHandle(self.0) }; + } + } +} + +struct LocalAllocation(*mut T); + +impl LocalAllocation { + const fn as_ptr(&self) -> *mut T { + self.0 + } +} + +impl Drop for LocalAllocation { + fn drop(&mut self) { + if !self.0.is_null() { + let _ = unsafe { LocalFree(self.0.cast::()) }; + } + } +} + +#[cfg(test)] +pub(super) fn assert_current_user_only(path: &Path, directory: bool) { + use windows_sys::Win32::Security::Authorization::GetNamedSecurityInfoW; + use windows_sys::Win32::Security::{ + ACCESS_ALLOWED_ACE, ACL_SIZE_INFORMATION, AclSizeInformation, EqualSid, GetAce, + GetAclInformation, GetSecurityDescriptorControl, PSECURITY_DESCRIPTOR, SE_DACL_PROTECTED, + }; + + const ACCESS_ALLOWED_ACE_TYPE: u8 = 0; + + let user = current_user_sid(path).unwrap(); + let path_wide = wide_path(path).unwrap(); + let mut owner: PSID = null_mut(); + let mut dacl: *mut ACL = null_mut(); + let mut security_descriptor: PSECURITY_DESCRIPTOR = null_mut(); + let status = unsafe { + GetNamedSecurityInfoW( + path_wide.as_ptr(), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &mut owner, + null_mut(), + &mut dacl, + null_mut(), + &mut security_descriptor, + ) + }; + assert_eq!( + status, + ERROR_SUCCESS, + "{}", + status_error("GetNamedSecurityInfoW", path, status) + ); + let _security_descriptor = LocalAllocation(security_descriptor); + assert!(!security_descriptor.is_null()); + assert!(!owner.is_null()); + assert!(!dacl.is_null()); + assert_ne!(unsafe { EqualSid(owner, user.sid()) }, 0); + + let mut control = 0; + let mut revision = 0; + assert_ne!( + unsafe { GetSecurityDescriptorControl(security_descriptor, &mut control, &mut revision) }, + 0 + ); + assert_ne!(control & SE_DACL_PROTECTED, 0); + + let mut acl_info = ACL_SIZE_INFORMATION::default(); + assert_ne!( + unsafe { + GetAclInformation( + dacl, + (&raw mut acl_info).cast(), + size_of::() as u32, + AclSizeInformation, + ) + }, + 0 + ); + assert_eq!(acl_info.AceCount, 1); + + let mut raw_ace: *mut c_void = null_mut(); + assert_ne!(unsafe { GetAce(dacl, 0, &mut raw_ace) }, 0); + let ace = raw_ace.cast::(); + assert_eq!(unsafe { (*ace).Header.AceType }, ACCESS_ALLOWED_ACE_TYPE); + assert_eq!(unsafe { (*ace).Mask }, FILE_ALL_ACCESS); + let expected_flags = if directory { + OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE + } else { + NO_INHERITANCE + }; + assert_eq!(u32::from(unsafe { (*ace).Header.AceFlags }), expected_flags); + let ace_sid = unsafe { + std::ptr::addr_of!((*ace).SidStart) + .cast_mut() + .cast::() + }; + assert_ne!(unsafe { EqualSid(ace_sid, user.sid()) }, 0); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn applies_current_user_only_acl_to_directory_and_file() { + let root = + std::env::temp_dir().join(format!("nyaterm-windows-acl-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + set_current_user_only(&root, true).unwrap(); + + let file = root.join("discovery.json"); + std::fs::write(&file, b"{}").unwrap(); + set_current_user_only(&file, false).unwrap(); + + assert_current_user_only(&root, true); + assert_current_user_only(&file, false); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn missing_path_returns_contextual_win32_error() { + let path = std::env::temp_dir() + .join(format!("nyaterm-missing-acl-test-{}", uuid::Uuid::new_v4())) + .join("discovery.json"); + let error = set_current_user_only(&path, false).unwrap_err(); + let message = error.to_string(); + assert!(message.contains("SetNamedSecurityInfoW")); + assert!(message.contains("win32_error=")); + assert!(message.contains(&path.display().to_string())); + } +} diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index bd06ade1..5fe886ad 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -25,6 +25,7 @@ pub mod remote_exec; mod session; pub mod sftp; pub mod ssh; +pub mod ssh_config; pub(crate) mod terminal_session; pub mod translate; pub mod vnc; diff --git a/src-tauri/src/core/sftp/mod.rs b/src-tauri/src/core/sftp/mod.rs index 1a28a055..67bb3b84 100644 --- a/src-tauri/src/core/sftp/mod.rs +++ b/src-tauri/src/core/sftp/mod.rs @@ -92,6 +92,7 @@ pub(crate) struct AutoRemoteFs { ssh_handle: Arc, cache_key: String, sftp_encoding: String, + sftp_pipeline_depth_override: Option, } impl AutoRemoteFs { @@ -101,12 +102,14 @@ impl AutoRemoteFs { port: u16, username: &str, sftp_encoding: &str, + sftp_pipeline_depth_override: Option, ) -> Self { Self { inner: RwLock::new(None), ssh_handle, cache_key: cache_key(host, port, username), sftp_encoding: sftp_encoding.to_string(), + sftp_pipeline_depth_override, } } @@ -151,6 +154,7 @@ impl AutoRemoteFs { return Ok(Box::new(SftpBackend::new( self.ssh_handle.clone(), &self.sftp_encoding, + self.sftp_pipeline_depth_override, ))); } Err(e) => { @@ -198,6 +202,7 @@ impl AutoRemoteFs { Box::new(SftpBackend::new( self.ssh_handle.clone(), &self.sftp_encoding, + self.sftp_pipeline_depth_override, )) }) } @@ -237,6 +242,7 @@ async fn get_ssh_info( String, String, String, + Option, )> { let sessions = manager.sessions.lock().await; let session = sessions @@ -251,7 +257,7 @@ async fn get_ssh_info( .downcast::() .map_err(|_| AppError::Config("Failed to get SSH handle".to_string()))?; - let (host, port, username, encoding, sftp_encoding) = + let (host, port, username, encoding, sftp_encoding, sftp_pipeline_depth_override) = if let Some(ref cfg_any) = session.ssh_config { if let Some(cfg) = cfg_any.downcast_ref::() { let sftp_encoding = if cfg.sftp.filename_encoding.trim().is_empty() { @@ -265,6 +271,7 @@ async fn get_ssh_info( cfg.username.clone(), cfg.encoding.clone(), sftp_encoding, + cfg.sftp.pipeline_depth, ) } else { ( @@ -273,6 +280,7 @@ async fn get_ssh_info( "unknown".to_string(), "UTF-8".to_string(), "UTF-8".to_string(), + None, ) } } else { @@ -282,10 +290,19 @@ async fn get_ssh_info( "unknown".to_string(), "UTF-8".to_string(), "UTF-8".to_string(), + None, ) }; - Ok((ssh_handle, host, port, username, encoding, sftp_encoding)) + Ok(( + ssh_handle, + host, + port, + username, + encoding, + sftp_encoding, + sftp_pipeline_depth_override, + )) } async fn get_or_create_auto_fs( @@ -307,7 +324,7 @@ async fn get_or_create_auto_fs( } } - let (ssh_handle, host, port, username, _encoding, sftp_encoding) = + let (ssh_handle, host, port, username, _encoding, sftp_encoding, sftp_pipeline_depth_override) = get_ssh_info(manager, session_id).await?; let auto_fs = Arc::new(AutoRemoteFs::new( ssh_handle, @@ -315,6 +332,7 @@ async fn get_or_create_auto_fs( port, &username, &sftp_encoding, + sftp_pipeline_depth_override, )); { @@ -2319,6 +2337,31 @@ pub async fn create_remote_symlink( Ok(()) } +pub async fn update_remote_symlink_target( + manager: Arc, + session_id: &str, + path: &str, + raw_path_token: Option<&str>, + target_path: &str, +) -> AppResult<()> { + let auto_fs = get_or_create_auto_fs(&manager, session_id).await?; + let guard = auto_fs.backend().await?; + let fs = guard.as_ref().unwrap(); + let path_ref = RemotePathRef::new(path, raw_path_token)?; + fs.update_symlink_target_ref(&path_ref, target_path).await?; + + tracing::debug!( + target: "user_action", + action = "update", + entity = "remote_symlink", + session_id = %session_id, + remote_path = path, + "User changed remote symbolic link target" + ); + + Ok(()) +} + pub async fn chmod_remote_file( manager: Arc, session_id: &str, diff --git a/src-tauri/src/core/sftp/scp_enhanced.rs b/src-tauri/src/core/sftp/scp_enhanced.rs index dc4fcc01..7d296302 100644 --- a/src-tauri/src/core/sftp/scp_enhanced.rs +++ b/src-tauri/src/core/sftp/scp_enhanced.rs @@ -18,6 +18,10 @@ struct ExecResult { stderr: Vec, } +fn parse_find_symlink_target(output: &[u8]) -> String { + String::from_utf8_lossy(output.strip_suffix(&[0]).unwrap_or(output)).into_owned() +} + impl ScpEnhancedBackend { pub(crate) fn new(ssh_handle: Arc) -> Self { Self { ssh_handle } @@ -731,6 +735,17 @@ impl RemoteFs for ScpEnhancedBackend { let is_dir = file_type.contains("directory"); let is_symlink = file_type.contains("symbolic link") || file_type.contains("symlink"); + let symlink_target = if is_symlink { + let output = self + .exec_ok(&format!( + "LC_ALL=C find {} -maxdepth 0 -printf '%l\\0'", + sh_quote(path) + )) + .await?; + Some(parse_find_symlink_target(&output)) + } else { + None + }; let is_symlink_to_dir = is_symlink && self .exec(&format!("test -d {}", sh_quote(path))) @@ -752,6 +767,7 @@ impl RemoteFs for ScpEnhancedBackend { name, is_dir, is_symlink, + symlink_target, size, permissions, owner, @@ -1323,3 +1339,25 @@ impl RemoteFs for ScpEnhancedBackend { .map_err(|error| AppError::Channel(format!("Failed to read copied file size: {error}"))) } } + +#[cfg(test)] +mod tests { + use super::parse_find_symlink_target; + + #[test] + fn find_target_parser_preserves_relative_and_trailing_spaces() { + assert_eq!( + parse_find_symlink_target(b"../release/v2\0"), + "../release/v2" + ); + assert_eq!(parse_find_symlink_target(b"release v3 \0"), "release v3 "); + } + + #[test] + fn find_target_parser_accepts_dangling_target_text() { + assert_eq!( + parse_find_symlink_target(b"missing-release\0"), + "missing-release" + ); + } +} diff --git a/src-tauri/src/core/sftp/scp_normal.rs b/src-tauri/src/core/sftp/scp_normal.rs index 194c1662..9d8c339e 100644 --- a/src-tauri/src/core/sftp/scp_normal.rs +++ b/src-tauri/src/core/sftp/scp_normal.rs @@ -300,16 +300,44 @@ async fn exec_command_with_stdin( }) } +fn split_ls_fields(line: &str) -> Option<(Vec<&str>, &str)> { + let bytes = line.as_bytes(); + let mut fields = Vec::with_capacity(8); + let mut cursor = 0; + while fields.len() < 8 { + while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() { + cursor += 1; + } + let start = cursor; + while cursor < bytes.len() && !bytes[cursor].is_ascii_whitespace() { + cursor += 1; + } + if start == cursor { + return None; + } + fields.push(&line[start..cursor]); + } + while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() { + cursor += 1; + } + (cursor < bytes.len()).then(|| (fields, &line[cursor..])) +} + +fn list_dir_command(path: &str) -> String { + format!("LC_ALL=C ls -la -n -- {}", sh_quote(path)) +} + +fn stat_command(path: &str) -> String { + format!("LC_ALL=C ls -lad -n -- {}", sh_quote(path)) +} + fn parse_ls_line(line: &str) -> Option { - let line = line.trim(); - if line.is_empty() || line.starts_with("total ") { + let line = line.trim_start(); + if line.trim_end().is_empty() || line.starts_with("total ") { return None; } - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() < 9 { - return None; - } + let (parts, raw_name) = split_ls_fields(line)?; let perms = parts[0]; if perms.len() < 10 { @@ -323,8 +351,6 @@ fn parse_ls_line(line: &str) -> Option { let group = parts[3].to_string(); let size: u64 = parts[4].parse().unwrap_or(0); - // parts[5..8] are month/day/time-or-year; everything from index 8 onward is the name - let raw_name = parts[8..].join(" "); if raw_name.is_empty() { return None; } @@ -336,7 +362,7 @@ fn parse_ls_line(line: &str) -> Option { raw_name.to_string() } } else { - raw_name + raw_name.to_string() }; if name == "." || name == ".." { @@ -365,14 +391,13 @@ fn remote_child_path(parent: &str, name: &str) -> String { } fn parse_ls_line_to_properties(line: &str, path: &str) -> AppResult { - let line = line.trim(); - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() < 9 { + let line = line.trim_start(); + let Some((parts, raw_name)) = split_ls_fields(line) else { return Err(AppError::Channel(format!( "Failed to parse stat output for '{}'", path ))); - } + }; let perms = parts[0]; if perms.len() < 10 { @@ -389,21 +414,27 @@ fn parse_ls_line_to_properties(line: &str, path: &str) -> AppResult ") { - raw_name[..pos].to_string() + ( + raw_name[..pos].to_string(), + Some(raw_name[pos + " -> ".len()..].to_string()), + ) } else { - raw_name.to_string() + return Err(AppError::Channel(format!( + "Symbolic link target was missing from stat output for '{}'", + path + ))); } } else { - raw_name + (raw_name.to_string(), None) }; Ok(FileProperties { name, is_dir, is_symlink, + symlink_target, size, permissions: perms.to_string(), owner: owner.clone(), @@ -419,11 +450,7 @@ async fn stat_remote_properties( ssh_handle: &Arc, path: &str, ) -> AppResult { - let result = exec_command( - ssh_handle, - &format!("LC_ALL=C ls -lad -- {}", sh_quote(path)), - ) - .await?; + let result = exec_command(ssh_handle, &stat_command(path)).await?; if result.exit_code != Some(0) { let stderr_text = String::from_utf8_lossy(&result.stderr); return Err(AppError::Channel(format!( @@ -865,9 +892,7 @@ impl RemoteFs for ScpNormalBackend { async fn list_dir(&self, path: &str) -> AppResult> { let listing_path = remote_dir_listing_path(path); - let output = self - .exec_ok(&format!("LC_ALL=C ls -la -- {}", sh_quote(&listing_path))) - .await?; + let output = self.exec_ok(&list_dir_command(&listing_path)).await?; let mut entries = Vec::new(); for line in output.lines() { if let Some(mut entry) = parse_ls_line(line) { @@ -885,9 +910,7 @@ impl RemoteFs for ScpNormalBackend { } async fn stat(&self, path: &str) -> AppResult { - let output = self - .exec_ok(&format!("LC_ALL=C ls -lad -- {}", sh_quote(path))) - .await?; + let output = self.exec_ok(&stat_command(path)).await?; let line = output .lines() .find(|l| !l.trim().is_empty() && !l.starts_with("total ")) @@ -1532,3 +1555,94 @@ impl RemoteFs for ScpNormalBackend { .map_err(|error| AppError::Channel(format!("Failed to read copied file size: {error}"))) } } + +#[cfg(test)] +mod tests { + use super::{list_dir_command, parse_ls_line, parse_ls_line_to_properties, stat_command}; + + #[test] + fn ls_parser_handles_numeric_owner_and_group() { + let entry = parse_ls_line("-rw-r--r-- 1 1000 100513 203 Aug 3 16:37 .zshrc").unwrap(); + assert_eq!(entry.name, ".zshrc"); + assert_eq!(entry.owner, "1000"); + assert_eq!(entry.group, "100513"); + assert_eq!(entry.size, 203); + assert!(!entry.is_dir); + } + + #[test] + fn ls_parser_keeps_hidden_directory_name() { + let entry = parse_ls_line("drwx------ 4 1000 100513 4096 Sep 1 09:31 .copilot").unwrap(); + assert_eq!(entry.name, ".copilot"); + assert!(entry.is_dir); + } + + #[test] + fn ls_parser_keeps_spaces_in_file_name() { + let entry = + parse_ls_line("-rw-r--r-- 1 1000 100513 123 Sep 1 09:31 hello world.txt").unwrap(); + assert_eq!(entry.name, "hello world.txt"); + assert!(!entry.is_dir); + } + + #[test] + fn ls_parser_keeps_spaces_in_directory_name() { + let entry = parse_ls_line("drwxr-xr-x 2 1000 100513 4096 Sep 1 09:31 My Folder").unwrap(); + assert_eq!(entry.name, "My Folder"); + assert!(entry.is_dir); + } + + #[test] + fn ls_parser_strips_symlink_target_from_name() { + let entry = + parse_ls_line("lrwxrwxrwx 1 1000 100513 11 Aug 29 12:00 current -> releases/v2") + .unwrap(); + assert_eq!(entry.name, "current"); + assert!(entry.is_symlink); + } + + #[test] + fn ls_commands_request_numeric_owner_and_group() { + assert_eq!( + list_dir_command("/home/user/My Folder"), + "LC_ALL=C ls -la -n -- '/home/user/My Folder'" + ); + assert_eq!( + stat_command("/home/user/My Folder"), + "LC_ALL=C ls -lad -n -- '/home/user/My Folder'" + ); + } + + #[test] + fn ls_properties_parser_keeps_relative_symlink_target() { + let props = parse_ls_line_to_properties( + "lrwxrwxrwx 1 root root 11 Aug 29 12:00 current -> releases/v2", + "/opt/app/current", + ) + .unwrap(); + assert_eq!(props.name, "current"); + assert!(props.is_symlink); + assert_eq!(props.symlink_target.as_deref(), Some("releases/v2")); + } + + #[test] + fn ls_properties_parser_keeps_dangling_and_spaced_target() { + let props = parse_ls_line_to_properties( + "lrwxrwxrwx 1 root root 19 Aug 29 12:00 current -> missing release ", + "/opt/app/current", + ) + .unwrap(); + assert_eq!(props.symlink_target.as_deref(), Some("missing release ")); + } + + #[test] + fn ls_properties_parser_sets_no_target_for_regular_file() { + let props = parse_ls_line_to_properties( + "-rw-r--r-- 1 root root 12 Aug 29 12:00 config.toml", + "/opt/app/config.toml", + ) + .unwrap(); + assert!(!props.is_symlink); + assert_eq!(props.symlink_target, None); + } +} diff --git a/src-tauri/src/core/sftp/sftp_backend.rs b/src-tauri/src/core/sftp/sftp_backend.rs index 7b4f8606..0476dea1 100644 --- a/src-tauri/src/core/sftp/sftp_backend.rs +++ b/src-tauri/src/core/sftp/sftp_backend.rs @@ -51,6 +51,8 @@ pub(crate) struct SftpBackend { path_cache: Arc>>>, /// Encoding for this connection (e.g., "UTF-8", "GBK") encoding: String, + /// Optional per-session override for single-file SFTP request pipelining. + pipeline_depth_override: Option, } #[derive(Default)] diff --git a/src-tauri/src/core/sftp/sftp_backend/config.rs b/src-tauri/src/core/sftp/sftp_backend/config.rs index 1107aed3..95f75e09 100644 --- a/src-tauri/src/core/sftp/sftp_backend/config.rs +++ b/src-tauri/src/core/sftp/sftp_backend/config.rs @@ -7,7 +7,8 @@ pub(super) const SFTP_MAX_REQUEST_KIB: usize = 256; pub(super) const SFTP_PIPELINE_TARGET_KIB: usize = 1024; pub(super) const SFTP_WRITE_PIPELINE_TARGET_KIB: usize = 2048; pub(super) const SFTP_MIN_PIPELINE_DEPTH: usize = 4; -pub(super) const SFTP_MAX_PIPELINE_DEPTH: usize = 16; +pub(super) const SFTP_AUTO_MAX_PIPELINE_DEPTH: usize = 16; +pub(super) const SFTP_USER_MAX_PIPELINE_DEPTH: usize = 64; pub(super) const SFTP_MIN_CONCURRENT_WRITES: usize = 8; pub(super) const SFTP_MAX_CONCURRENT_WRITES: usize = 16; pub(super) const SFTP_PACKET_OVERHEAD_RESERVE: usize = 1024; @@ -27,15 +28,22 @@ pub(super) const SFTP_CHANNEL_OPEN_RETRY_DELAYS: [Duration; 3] = [ Duration::from_millis(300), ]; -pub(super) fn sftp_pipeline_config(ts: &crate::config::TransferSettings) -> (usize, usize, usize) { +pub(super) fn sftp_pipeline_config( + ts: &crate::config::TransferSettings, + pipeline_depth_override: Option, +) -> (usize, usize, usize) { let request_kib = (ts.transfer_buffer_size as usize).clamp(SFTP_MIN_REQUEST_KIB, SFTP_MAX_REQUEST_KIB); - let pipeline_depth = SFTP_PIPELINE_TARGET_KIB + let automatic_pipeline_depth = SFTP_PIPELINE_TARGET_KIB .div_ceil(request_kib) - .clamp(SFTP_MIN_PIPELINE_DEPTH, SFTP_MAX_PIPELINE_DEPTH); - let max_concurrent_writes = SFTP_WRITE_PIPELINE_TARGET_KIB + .clamp(SFTP_MIN_PIPELINE_DEPTH, SFTP_AUTO_MAX_PIPELINE_DEPTH); + let automatic_max_concurrent_writes = SFTP_WRITE_PIPELINE_TARGET_KIB .div_ceil(request_kib) .clamp(SFTP_MIN_CONCURRENT_WRITES, SFTP_MAX_CONCURRENT_WRITES); + let pipeline_depth_override = pipeline_depth_override + .map(|value| (value as usize).clamp(SFTP_MIN_PIPELINE_DEPTH, SFTP_USER_MAX_PIPELINE_DEPTH)); + let pipeline_depth = pipeline_depth_override.unwrap_or(automatic_pipeline_depth); + let max_concurrent_writes = pipeline_depth_override.unwrap_or(automatic_max_concurrent_writes); (request_kib, pipeline_depth, max_concurrent_writes) } diff --git a/src-tauri/src/core/sftp/sftp_backend/copy.rs b/src-tauri/src/core/sftp/sftp_backend/copy.rs index 654390e6..66339a00 100644 --- a/src-tauri/src/core/sftp/sftp_backend/copy.rs +++ b/src-tauri/src/core/sftp/sftp_backend/copy.rs @@ -480,7 +480,8 @@ impl SftpBackend { use tokio::io::{AsyncReadExt, AsyncWriteExt}; let settings = copy_transfer_settings(app); - let (request_kib, pipeline_depth, max_concurrent_writes) = sftp_pipeline_config(&settings); + let (request_kib, pipeline_depth, max_concurrent_writes) = + sftp_pipeline_config(&settings, self.pipeline_depth_override); let chunk_size = sftp_payload_size(request_kib); let started = Instant::now(); let controller = create_child_file_transfer_controller( @@ -632,6 +633,9 @@ impl SftpBackend { ) -> AppResult<()> { use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let settings = copy_transfer_settings(app); + let (request_kib, pipeline_depth, max_concurrent_writes) = + sftp_pipeline_config(&settings, self.pipeline_depth_override); let controller = create_child_file_transfer_controller( transfer_id, source_session_id, @@ -656,50 +660,88 @@ impl SftpBackend { })?; } - let sftp = self.open_sftp().await?; - let total_size = sftp + let sftp = self + .open_sftp_with_client_config(sftp_client_config( + request_kib, + max_concurrent_writes, + )) + .await?; + let remote_size = sftp .metadata(source_path) .await .ok() - .and_then(|attrs| attrs.size) - .unwrap_or(0); + .and_then(|attrs| attrs.size); + let total_size = remote_size.unwrap_or(0); controller.update_progress(0, total_size); - let mut source_file = sftp.open(source_path).await.map_err(|error| { - AppError::Channel(format!("Source connection read open failed: {error}")) - })?; let mut temp_file = tokio::fs::File::create(&temp).await.map_err(|error| { AppError::Channel(format!("Failed to create temporary target file: {error}")) })?; - let mut buffer = vec![0_u8; 512 * 1024]; - let mut bytes_written = 0_u64; - let mut last_progress = Instant::now(); + let temp_path = temp.to_string_lossy().to_string(); - loop { - wait_for_transfer_ready(&controller).await?; - let read = source_file.read(&mut buffer).await.map_err(|error| { - AppError::Channel(format!( - "Source connection disconnected or read failed for {source_path}: {error}" - )) + let bytes_written = if let Some(total_size) = remote_size { + if total_size > 0 { + let _ = temp_file.set_len(total_size).await; + } + download_known_size_to_local_file( + &sftp, + source_path, + &temp_path, + &mut temp_file, + total_size, + request_kib, + pipeline_depth, + pipeline_depth, + &controller, + None, + &self.path_cache, + |current, _delta| controller.update_progress(current, total_size), + |current| { + controller.update_progress(current, total_size); + let _ = app.emit( + "transfer-event", + &controller.build_event("progress", total_size, None), + ); + }, + ) + .await? + } else { + let mut source_file = sftp.open(source_path).await.map_err(|error| { + AppError::Channel(format!("Source connection read open failed: {error}")) })?; - if read == 0 { - break; - } - temp_file - .write_all(&buffer[..read]) - .await - .map_err(|error| { - AppError::Channel(format!("Failed to write temporary target file: {error}")) + let mut buffer = vec![0_u8; sftp_payload_size(request_kib)]; + let mut bytes_written = 0_u64; + let mut last_progress = Instant::now(); + loop { + wait_for_transfer_ready(&controller).await?; + let read = source_file.read(&mut buffer).await.map_err(|error| { + AppError::Channel(format!( + "Source connection disconnected or read failed for {source_path}: {error}" + )) })?; - bytes_written = bytes_written.saturating_add(read as u64); - controller.update_progress(bytes_written, total_size); - if last_progress.elapsed() >= TRANSFER_PROGRESS_INTERVAL { - last_progress = Instant::now(); - let _ = app.emit( - "transfer-event", - &controller.build_event("progress", total_size, None), - ); + if read == 0 { + break; + } + temp_file + .write_all(&buffer[..read]) + .await + .map_err(|error| { + AppError::Channel(format!( + "Failed to write temporary target file: {error}" + )) + })?; + bytes_written = bytes_written.saturating_add(read as u64); + controller.update_progress(bytes_written, 0); + if last_progress.elapsed() >= TRANSFER_PROGRESS_INTERVAL { + last_progress = Instant::now(); + let _ = app.emit( + "transfer-event", + &controller.build_event("progress", 0, None), + ); + } } - } + close_download_remote_file(source_file, source_path, &temp_path, None).await?; + bytes_written + }; temp_file.flush().await.map_err(|error| { AppError::Channel(format!("Failed to flush temporary target file: {error}")) })?; @@ -945,7 +987,8 @@ impl SftpBackend { use tokio::io::{AsyncReadExt, AsyncWriteExt}; let settings = copy_transfer_settings(app); - let (request_kib, pipeline_depth, max_concurrent_writes) = sftp_pipeline_config(&settings); + let (request_kib, pipeline_depth, max_concurrent_writes) = + sftp_pipeline_config(&settings, self.pipeline_depth_override); let chunk_size = sftp_payload_size(request_kib); let started = Instant::now(); let controller = create_directory_transfer_controller( @@ -1123,7 +1166,8 @@ impl SftpBackend { use tokio::io::{AsyncReadExt, AsyncWriteExt}; let settings = copy_transfer_settings(app); - let (request_kib, pipeline_depth, max_concurrent_writes) = sftp_pipeline_config(&settings); + let (request_kib, pipeline_depth, max_concurrent_writes) = + sftp_pipeline_config(&settings, self.pipeline_depth_override); let started = Instant::now(); let controller = create_directory_transfer_controller( transfer_id, diff --git a/src-tauri/src/core/sftp/sftp_backend/directory.rs b/src-tauri/src/core/sftp/sftp_backend/directory.rs index 69e16b21..aa185492 100644 --- a/src-tauri/src/core/sftp/sftp_backend/directory.rs +++ b/src-tauri/src/core/sftp/sftp_backend/directory.rs @@ -371,7 +371,8 @@ impl SftpBackend { directory_controller: &Arc, transfer_settings: &crate::config::TransferSettings, ) -> AppResult { - let (request_kib, _, max_concurrent_writes) = sftp_pipeline_config(transfer_settings); + let (request_kib, _, max_concurrent_writes) = + sftp_pipeline_config(transfer_settings, self.pipeline_depth_override); let sftp = self .open_sftp_with_client_config(sftp_client_config(request_kib, max_concurrent_writes)) .await?; @@ -479,7 +480,8 @@ impl SftpBackend { }); } - let (request_kib, _, max_concurrent_writes) = sftp_pipeline_config(transfer_settings); + let (request_kib, pipeline_depth, max_concurrent_writes) = + sftp_pipeline_config(transfer_settings, self.pipeline_depth_override); let pool = SftpSessionPool::new( self, concurrency.session_pool_size, @@ -492,6 +494,8 @@ impl SftpBackend { inventory, directory_controller, transfer_settings, + request_kib, + pipeline_depth, concurrency, self.path_cache.clone(), ) @@ -517,7 +521,8 @@ impl SftpBackend { }); } - let (request_kib, _, max_concurrent_writes) = sftp_pipeline_config(transfer_settings); + let (request_kib, _, max_concurrent_writes) = + sftp_pipeline_config(transfer_settings, self.pipeline_depth_override); let pool = SftpSessionPool::new( self, concurrency.session_pool_size, @@ -530,6 +535,7 @@ impl SftpBackend { inventory, directory_controller.clone(), transfer_settings, + request_kib, concurrency, ) .await; @@ -653,11 +659,12 @@ pub(super) async fn run_download_directory_workers( inventory: RemoteDirectoryInventory, directory_controller: Arc, transfer_settings: &crate::config::TransferSettings, + request_kib: usize, + requested_pipeline_depth: usize, concurrency: SftpDirectoryConcurrency, path_cache: Arc>>>, ) -> AppResult { let worker_count = sftp_directory_file_concurrency(inventory.files.len(), concurrency); - let (_, requested_pipeline_depth, _) = sftp_pipeline_config(transfer_settings); let effective_pipeline_depth = sftp_directory_download_pipeline_cap( inventory.max_open_handles, concurrency.session_pool_size, @@ -730,6 +737,8 @@ pub(super) async fn run_download_directory_workers( &transfer_settings, &completed_bytes, total_size, + request_kib, + requested_pipeline_depth, effective_pipeline_depth, &path_cache, ) @@ -809,6 +818,7 @@ pub(super) async fn run_upload_directory_workers( inventory: LocalDirectoryInventory, directory_controller: Arc, transfer_settings: &crate::config::TransferSettings, + request_kib: usize, concurrency: SftpDirectoryConcurrency, ) -> AppResult { let worker_count = sftp_directory_file_concurrency(inventory.files.len(), concurrency); @@ -855,6 +865,7 @@ pub(super) async fn run_upload_directory_workers( &transfer_settings, &completed_bytes, total_size, + request_kib, ) .await?; let completed = completed_count.fetch_add(1, Ordering::SeqCst) + 1; @@ -934,6 +945,8 @@ pub(super) async fn download_directory_file_with_session( transfer_settings: &crate::config::TransferSettings, completed_bytes: &Arc, total_size: u64, + request_kib: usize, + pipeline_depth: usize, max_pipeline_depth: usize, path_cache: &RwLock>>, ) -> AppResult { @@ -959,7 +972,6 @@ pub(super) async fn download_directory_file_with_session( } let mut bytes_transferred = 0u64; - let (request_kib, pipeline_depth, _) = sftp_pipeline_config(transfer_settings); let payload_bytes = sftp_payload_size(request_kib); if file.size > 0 { let app_for_progress = app.clone(); @@ -1029,6 +1041,7 @@ pub(super) async fn upload_directory_file_with_session( transfer_settings: &crate::config::TransferSettings, completed_bytes: &Arc, total_size: u64, + request_kib: usize, ) -> AppResult { use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -1046,7 +1059,6 @@ pub(super) async fn upload_directory_file_with_session( )) })?; - let (request_kib, _, _) = sftp_pipeline_config(transfer_settings); let mut buf = vec![0u8; sftp_payload_size(request_kib)]; let mut bytes_transferred = 0u64; let mut last_progress = Instant::now(); diff --git a/src-tauri/src/core/sftp/sftp_backend/file_transfer.rs b/src-tauri/src/core/sftp/sftp_backend/file_transfer.rs index 944290b8..e103b508 100644 --- a/src-tauri/src/core/sftp/sftp_backend/file_transfer.rs +++ b/src-tauri/src/core/sftp/sftp_backend/file_transfer.rs @@ -355,7 +355,8 @@ pub(super) async fn download_remote_file_inner_with_controller( &controller.build_event("started", 0, None), ); - let (request_kib, pipeline_depth, max_concurrent_writes) = sftp_pipeline_config(ts); + let (request_kib, pipeline_depth, max_concurrent_writes) = + sftp_pipeline_config(ts, backend.pipeline_depth_override); let chunk_size = sftp_payload_size(request_kib) as u64; let transfer_started = Instant::now(); @@ -550,7 +551,8 @@ pub(super) async fn upload_local_file_inner_with_controller( &controller.build_event("started", 0, None), ); - let (request_kib, pipeline_depth, max_concurrent_writes) = sftp_pipeline_config(ts); + let (request_kib, pipeline_depth, max_concurrent_writes) = + sftp_pipeline_config(ts, backend.pipeline_depth_override); let chunk_size = sftp_payload_size(request_kib); let transfer_started = Instant::now(); diff --git a/src-tauri/src/core/sftp/sftp_backend/fs.rs b/src-tauri/src/core/sftp/sftp_backend/fs.rs index 1abcd202..ecd1c25c 100644 --- a/src-tauri/src/core/sftp/sftp_backend/fs.rs +++ b/src-tauri/src/core/sftp/sftp_backend/fs.rs @@ -166,6 +166,17 @@ impl RemoteFs for SftpBackend { let raw_path = self.remote_path_bytes(path); let attrs = sftp.symlink_metadata_bytes(raw_path.clone()).await?; let is_symlink = sftp_attrs_is_symlink(&attrs); + let symlink_target = if is_symlink { + match sftp.read_link_bytes(raw_path.clone()).await { + Ok(target) => Some(self.decode_path_from_sftp(&target)), + Err(error) => { + let _ = sftp.close().await; + return Err(error.into()); + } + } + } else { + None + }; let target_attrs = if is_symlink { sftp.metadata_bytes(raw_path).await.ok() } else { @@ -213,6 +224,7 @@ impl RemoteFs for SftpBackend { name, is_dir, is_symlink, + symlink_target, size: attrs.size.unwrap_or(0), permissions, owner, @@ -326,8 +338,21 @@ impl RemoteFs for SftpBackend { } async fn create_symlink(&self, link_path: &str, target_path: &str) -> AppResult<()> { + let link_path = RemotePathRef::new(link_path, None)?; + self.create_symlink_ref(&link_path, target_path).await + } + + async fn create_symlink_ref( + &self, + link_path: &RemotePathRef, + target_path: &str, + ) -> AppResult<()> { let sftp = self.open_sftp().await?; - sftp.symlink_openssh(target_path, link_path).await?; + sftp.symlink_openssh_bytes( + self.encode_path_for_sftp(target_path), + self.remote_path_bytes(link_path), + ) + .await?; let _ = sftp.close().await; Ok(()) } @@ -802,7 +827,7 @@ impl RemoteFs for SftpBackend { .map(|s| s.transfer) .unwrap_or_default(); let (request_kib, pipeline_depth, max_concurrent_writes) = - sftp_pipeline_config(&transfer_settings); + sftp_pipeline_config(&transfer_settings, self.pipeline_depth_override); let transfer_started = Instant::now(); let directory_controller = create_directory_transfer_controller( transfer_id, @@ -901,7 +926,7 @@ impl RemoteFs for SftpBackend { let _ = sftp_for_check.close().await; let (request_kib, pipeline_depth, max_concurrent_writes) = - sftp_pipeline_config(transfer_settings); + sftp_pipeline_config(transfer_settings, self.pipeline_depth_override); let transfer_started = Instant::now(); let directory_controller = create_directory_transfer_controller( transfer_id, diff --git a/src-tauri/src/core/sftp/sftp_backend/path.rs b/src-tauri/src/core/sftp/sftp_backend/path.rs index df0979d5..e7ebf0be 100644 --- a/src-tauri/src/core/sftp/sftp_backend/path.rs +++ b/src-tauri/src/core/sftp/sftp_backend/path.rs @@ -3,12 +3,17 @@ use super::*; impl SftpBackend { - pub(crate) fn new(ssh_handle: Arc, encoding: &str) -> Self { + pub(crate) fn new( + ssh_handle: Arc, + encoding: &str, + pipeline_depth_override: Option, + ) -> Self { Self { ssh_handle, identity_cache: Arc::new(RwLock::new(RemoteIdentityCache::default())), path_cache: Arc::new(RwLock::new(HashMap::new())), encoding: encoding.to_string(), + pipeline_depth_override, } } diff --git a/src-tauri/src/core/sftp/sftp_backend/tests.rs b/src-tauri/src/core/sftp/sftp_backend/tests.rs index 537c683d..d0136a75 100644 --- a/src-tauri/src/core/sftp/sftp_backend/tests.rs +++ b/src-tauri/src/core/sftp/sftp_backend/tests.rs @@ -193,6 +193,48 @@ fn write_text_permission_restore_skips_missing_permission_attrs() { assert_eq!(permissions_to_preserve_after_write(None), None); } +fn transfer_settings_with_request_kib(request_kib: u32) -> crate::config::TransferSettings { + crate::config::TransferSettings { + transfer_buffer_size: request_kib, + ..crate::config::TransferSettings::default() + } +} + +#[test] +fn automatic_pipeline_config_keeps_existing_depths() { + let cases = [(64, 16, 16), (128, 8, 16), (256, 4, 8)]; + + for (request_kib, expected_download_depth, expected_concurrent_writes) in cases { + let settings = transfer_settings_with_request_kib(request_kib); + assert_eq!( + sftp_pipeline_config(&settings, None), + ( + request_kib as usize, + expected_download_depth, + expected_concurrent_writes, + ) + ); + } +} + +#[test] +fn manual_pipeline_depth_overrides_downloads_and_uploads() { + for request_kib in [64, 128, 256] { + let settings = transfer_settings_with_request_kib(request_kib); + assert_eq!( + sftp_pipeline_config(&settings, Some(32)), + (request_kib as usize, 32, 32) + ); + } +} + +#[test] +fn manual_pipeline_depth_is_defensively_clamped() { + let settings = transfer_settings_with_request_kib(128); + assert_eq!(sftp_pipeline_config(&settings, Some(1)), (128, 4, 4)); + assert_eq!(sftp_pipeline_config(&settings, Some(128)), (128, 64, 64)); +} + #[test] fn directory_concurrency_keeps_at_least_one_worker() { let concurrency = sftp_directory_concurrency(Some(2)); @@ -215,6 +257,14 @@ fn directory_pipeline_respects_server_handle_budget() { ); } +#[test] +fn directory_pipeline_caps_manual_depth_to_server_handle_budget() { + assert_eq!( + sftp_directory_download_pipeline_cap(Some(128), 2, 16, 64), + 15 + ); +} + #[test] fn directory_pipeline_never_returns_zero() { assert_eq!(sftp_directory_download_pipeline_cap(Some(2), 2, 16, 16), 1); diff --git a/src-tauri/src/core/sftp/traits.rs b/src-tauri/src/core/sftp/traits.rs index 9a65ced3..57f5da35 100644 --- a/src-tauri/src/core/sftp/traits.rs +++ b/src-tauri/src/core/sftp/traits.rs @@ -43,6 +43,21 @@ pub(crate) trait RemoteFs: Send + Sync { } async fn create_file(&self, path: &str, mode: Option) -> AppResult<()>; async fn create_symlink(&self, link_path: &str, target_path: &str) -> AppResult<()>; + async fn create_symlink_ref( + &self, + link_path: &RemotePathRef, + target_path: &str, + ) -> AppResult<()> { + self.create_symlink(link_path.display_path(), target_path) + .await + } + async fn update_symlink_target_ref( + &self, + path: &RemotePathRef, + target_path: &str, + ) -> AppResult<()> { + replace_symlink_target(&RemoteFsSymlinkOps(self), path, target_path).await + } async fn update_attrs(&self, path: &str, update: &RemoteFileAttributeUpdate) -> AppResult<()>; async fn update_attrs_ref( &self, @@ -124,3 +139,365 @@ pub(crate) trait RemoteFs: Send + Sync { parent_controller: Option>, ) -> AppResult; } + +#[async_trait::async_trait] +trait SymlinkReplacementOps: Send + Sync { + async fn stat(&self, path: &RemotePathRef) -> AppResult; + async fn create(&self, path: &RemotePathRef, target_path: &str) -> AppResult<()>; + async fn rename(&self, old_path: &RemotePathRef, new_path: &RemotePathRef) -> AppResult<()>; + async fn remove(&self, path: &RemotePathRef) -> AppResult<()>; +} + +struct RemoteFsSymlinkOps<'a, T: RemoteFs + ?Sized>(&'a T); + +#[async_trait::async_trait] +impl SymlinkReplacementOps for RemoteFsSymlinkOps<'_, T> { + async fn stat(&self, path: &RemotePathRef) -> AppResult { + self.0.stat_ref(path).await + } + + async fn create(&self, path: &RemotePathRef, target_path: &str) -> AppResult<()> { + self.0.create_symlink_ref(path, target_path).await + } + + async fn rename(&self, old_path: &RemotePathRef, new_path: &RemotePathRef) -> AppResult<()> { + self.0.rename_ref(old_path, new_path).await + } + + async fn remove(&self, path: &RemotePathRef) -> AppResult<()> { + self.0.remove_file_ref(path).await + } +} + +async fn ensure_symlink( + fs: &(impl SymlinkReplacementOps + ?Sized), + path: &RemotePathRef, +) -> AppResult<()> { + let properties = fs.stat(path).await.map_err(|error| { + crate::error::AppError::Channel(format!( + "Failed to verify symbolic link '{}': {error}", + path.display_path() + )) + })?; + if properties.is_symlink { + Ok(()) + } else { + Err(crate::error::AppError::Config(format!( + "Remote path '{}' is no longer a symbolic link; refusing to replace it", + path.display_path() + ))) + } +} + +async fn cleanup_after_failure( + fs: &(impl SymlinkReplacementOps + ?Sized), + path: &RemotePathRef, + primary_error: crate::error::AppError, +) -> crate::error::AppError { + match fs.remove(path).await { + Ok(()) => primary_error, + Err(cleanup_error) => crate::error::AppError::Channel(format!( + "{primary_error}; cleanup of '{}' also failed: {cleanup_error}", + path.display_path() + )), + } +} + +async fn replace_symlink_target( + fs: &(impl SymlinkReplacementOps + ?Sized), + original: &RemotePathRef, + target_path: &str, +) -> AppResult<()> { + if target_path.trim().is_empty() { + return Err(crate::error::AppError::Config( + "Symbolic link target cannot be empty".to_string(), + )); + } + + ensure_symlink(fs, original).await?; + + let suffix = uuid::Uuid::new_v4().simple().to_string(); + let temp = original.sibling(&format!(".nyaterm-link-{suffix}")); + let backup = original.sibling(&format!(".nyaterm-backup-{suffix}")); + + fs.create(&temp, target_path).await.map_err(|error| { + crate::error::AppError::Channel(format!( + "Failed to create temporary symbolic link '{}': {error}", + temp.display_path() + )) + })?; + + if let Err(error) = ensure_symlink(fs, original).await { + return Err(cleanup_after_failure(fs, &temp, error).await); + } + + if let Err(error) = fs.rename(original, &backup).await { + let error = crate::error::AppError::Channel(format!( + "Failed to move original symbolic link '{}' to backup: {error}", + original.display_path() + )); + return Err(cleanup_after_failure(fs, &temp, error).await); + } + + if let Err(replacement_error) = fs.rename(&temp, original).await { + return match fs.rename(&backup, original).await { + Ok(()) => { + let error = crate::error::AppError::Channel(format!( + "Failed to replace symbolic link '{}': {replacement_error}; the original link was restored", + original.display_path() + )); + Err(cleanup_after_failure(fs, &temp, error).await) + } + Err(rollback_error) => Err(crate::error::AppError::Channel(format!( + "Failed to replace symbolic link '{}': {replacement_error}; rollback from '{}' also failed: {rollback_error}", + original.display_path(), + backup.display_path() + ))), + }; + } + + if let Err(error) = fs.remove(&backup).await { + tracing::warn!( + original_path = original.display_path(), + backup_path = backup.display_path(), + error = %error, + "Symbolic link target was updated, but backup cleanup failed" + ); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::AppError; + use std::collections::HashSet; + use std::sync::Mutex; + + struct RecordingOps { + actions: Mutex>, + failures: Mutex>, + is_symlink: bool, + } + + impl Default for RecordingOps { + fn default() -> Self { + Self { + actions: Mutex::new(Vec::new()), + failures: Mutex::new(HashSet::new()), + is_symlink: true, + } + } + } + + impl RecordingOps { + fn failing(actions: &[&str]) -> Self { + Self { + actions: Mutex::new(Vec::new()), + failures: Mutex::new(actions.iter().map(|value| (*value).to_string()).collect()), + is_symlink: true, + } + } + + fn regular_file() -> Self { + Self { + is_symlink: false, + ..Self::default() + } + } + + fn record(&self, action: String, failure_key: &str) -> AppResult<()> { + self.actions.lock().unwrap().push(action); + if self.failures.lock().unwrap().contains(failure_key) { + Err(AppError::Channel(format!("injected {failure_key} failure"))) + } else { + Ok(()) + } + } + + fn actions(&self) -> Vec { + self.actions.lock().unwrap().clone() + } + } + + #[async_trait::async_trait] + impl SymlinkReplacementOps for RecordingOps { + async fn stat(&self, path: &RemotePathRef) -> AppResult { + self.record(format!("stat:{}", path.display_path()), "stat")?; + Ok(FileProperties { + name: "current".to_string(), + is_dir: false, + is_symlink: self.is_symlink, + symlink_target: self.is_symlink.then(|| "releases/v2".to_string()), + size: 0, + permissions: "lrwxrwxrwx".to_string(), + owner: "root".to_string(), + group: "root".to_string(), + uid: "0".to_string(), + gid: "0".to_string(), + mtime: 0, + atime: 0, + }) + } + + async fn create(&self, path: &RemotePathRef, target_path: &str) -> AppResult<()> { + self.record( + format!("create:{}->{target_path}", path.display_path()), + "create", + ) + } + + async fn rename( + &self, + old_path: &RemotePathRef, + new_path: &RemotePathRef, + ) -> AppResult<()> { + let failure_key = if old_path.display_path().contains("nyaterm-link") { + "commit" + } else if old_path.display_path().contains("nyaterm-backup") { + "rollback" + } else { + "backup" + }; + self.record( + format!( + "rename:{}->{}", + old_path.display_path(), + new_path.display_path() + ), + failure_key, + ) + } + + async fn remove(&self, path: &RemotePathRef) -> AppResult<()> { + let failure_key = if path.display_path().contains("nyaterm-backup") { + "remove_backup" + } else { + "remove_temp" + }; + self.record(format!("remove:{}", path.display_path()), failure_key) + } + } + + fn original() -> RemotePathRef { + RemotePathRef::new("/opt/app/current", None).unwrap() + } + + #[tokio::test] + async fn replacement_creates_temp_before_moving_original() { + let ops = RecordingOps::default(); + replace_symlink_target(&ops, &original(), " releases/v3 ") + .await + .unwrap(); + let actions = ops.actions(); + let create = actions + .iter() + .position(|action| action.starts_with("create:")) + .unwrap(); + let backup = actions + .iter() + .position(|action| action.starts_with("rename:/opt/app/current->")) + .unwrap(); + assert!(create < backup); + assert!(actions[create].ends_with("-> releases/v3 ")); + assert!( + actions + .last() + .unwrap() + .starts_with("remove:/opt/app/.nyaterm-backup-") + ); + } + + #[tokio::test] + async fn missing_or_non_symlink_original_is_never_recreated() { + let missing = RecordingOps::failing(&["stat"]); + replace_symlink_target(&missing, &original(), "releases/v3") + .await + .unwrap_err(); + assert_eq!(missing.actions(), ["stat:/opt/app/current"]); + + let regular = RecordingOps::regular_file(); + let error = replace_symlink_target(®ular, &original(), "releases/v3") + .await + .unwrap_err(); + assert!(error.to_string().contains("no longer a symbolic link")); + assert_eq!(regular.actions(), ["stat:/opt/app/current"]); + } + + #[tokio::test] + async fn temporary_link_failure_leaves_original_untouched() { + let ops = RecordingOps::failing(&["create"]); + replace_symlink_target(&ops, &original(), "releases/v3") + .await + .unwrap_err(); + let actions = ops.actions(); + assert_eq!(actions.len(), 2); + assert_eq!(actions[0], "stat:/opt/app/current"); + assert!(actions[1].starts_with("create:/opt/app/.nyaterm-link-")); + } + + #[tokio::test] + async fn replacement_failure_rolls_back_original() { + let ops = RecordingOps::failing(&["commit"]); + let error = replace_symlink_target(&ops, &original(), "releases/v3") + .await + .unwrap_err(); + let actions = ops.actions(); + assert!(error.to_string().contains("original link was restored")); + assert!(actions.iter().any(|action| { + action.starts_with("rename:/opt/app/.nyaterm-backup-") + && action.ends_with("->/opt/app/current") + })); + assert!( + actions + .last() + .unwrap() + .starts_with("remove:/opt/app/.nyaterm-link-") + ); + } + + #[tokio::test] + async fn replacement_and_rollback_failures_are_both_reported() { + let ops = RecordingOps::failing(&["commit", "rollback"]); + let error = replace_symlink_target(&ops, &original(), "releases/v3") + .await + .unwrap_err() + .to_string(); + assert!(error.contains("injected commit failure")); + assert!(error.contains("injected rollback failure")); + } + + #[tokio::test] + async fn failed_backup_move_cleans_temp_without_committing() { + let ops = RecordingOps::failing(&["backup"]); + replace_symlink_target(&ops, &original(), "releases/v3") + .await + .unwrap_err(); + let actions = ops.actions(); + assert!( + actions + .last() + .unwrap() + .starts_with("remove:/opt/app/.nyaterm-link-") + ); + assert!(!actions.iter().any(|action| { + action.starts_with("rename:/opt/app/.nyaterm-link-") + && action.ends_with("->/opt/app/current") + })); + } + + #[tokio::test] + async fn backup_cleanup_failure_does_not_undo_successful_replacement() { + let ops = RecordingOps::failing(&["remove_backup"]); + replace_symlink_target(&ops, &original(), "missing-release") + .await + .unwrap(); + assert!( + ops.actions() + .last() + .unwrap() + .starts_with("remove:/opt/app/.nyaterm-backup-") + ); + } +} diff --git a/src-tauri/src/core/sftp/util.rs b/src-tauri/src/core/sftp/util.rs index ae0e4438..1544b8f3 100644 --- a/src-tauri/src/core/sftp/util.rs +++ b/src-tauri/src/core/sftp/util.rs @@ -59,6 +59,43 @@ impl RemotePathRef { pub(crate) fn raw_path(&self) -> Option<&[u8]> { self.raw_path.as_deref() } + + pub(crate) fn sibling(&self, file_name: &str) -> Self { + let display_path = sibling_path(self.display_path().as_bytes(), file_name.as_bytes()) + .map_or_else( + || file_name.to_string(), + |bytes| String::from_utf8_lossy(&bytes).into_owned(), + ); + let raw_path = self + .raw_path() + .and_then(|path| sibling_path(path, file_name.as_bytes())); + Self { + display_path, + raw_path, + } + } +} + +fn sibling_path(path: &[u8], file_name: &[u8]) -> Option> { + if file_name.is_empty() { + return None; + } + let parent = path + .iter() + .rposition(|byte| *byte == b'/') + .map(|index| &path[..index]) + .unwrap_or_default(); + let mut sibling = Vec::with_capacity(parent.len() + file_name.len() + 1); + if parent.is_empty() { + if path.starts_with(b"/") { + sibling.push(b'/'); + } + } else { + sibling.extend_from_slice(parent); + sibling.push(b'/'); + } + sibling.extend_from_slice(file_name); + Some(sibling) } pub(crate) fn raw_path_token(raw_path: &[u8]) -> String { @@ -76,6 +113,7 @@ pub struct FileProperties { pub name: String, pub is_dir: bool, pub is_symlink: bool, + pub symlink_target: Option, pub size: u64, pub permissions: String, pub owner: String, @@ -533,6 +571,24 @@ mod tests { assert_eq!(path_ref.raw_path().unwrap(), raw_path); } + #[test] + fn remote_path_sibling_preserves_raw_parent_bytes() { + let raw_path = b"/remote/\x80dir/\x81link"; + let token = raw_path_token(raw_path); + let path_ref = + RemotePathRef::new("/remote/display-dir/display-link", Some(&token)).unwrap(); + let sibling = path_ref.sibling(".nyaterm-link-test"); + + assert_eq!( + sibling.raw_path().unwrap(), + b"/remote/\x80dir/.nyaterm-link-test" + ); + assert_eq!( + sibling.display_path(), + "/remote/display-dir/.nyaterm-link-test" + ); + } + #[test] fn percent_encodes_windows_invalid_characters() { assert_eq!( @@ -674,6 +730,7 @@ mod tests { name: "file".to_string(), is_dir: false, is_symlink: false, + symlink_target: None, size: 0, permissions: permissions.to_string(), owner: owner.to_string(), diff --git a/src-tauri/src/core/ssh_config.rs b/src-tauri/src/core/ssh_config.rs new file mode 100644 index 00000000..15c6d5a3 --- /dev/null +++ b/src-tauri/src/core/ssh_config.rs @@ -0,0 +1,1385 @@ +//! Parses `~/.ssh/config` and resolves host aliases, including ProxyJump chains. +//! +//! Supports a subset of OpenSSH client configuration relevant for session +//! management: Host patterns (wildcards, negation), HostName, Port, User, +//! IdentityFile, ProxyJump (single/multi-hop), HostKeyAlias, and Include +//! directives (recursive with cycle detection and glob support). + +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::config::{ConnectionAuth, ConnectionType, SavedConnection}; +use crate::error::{AppError, AppResult}; + +/// One parsed `Host` block from the SSH config file. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SshConfigHost { + pub patterns: Vec, + pub name: String, + pub host_name: Option, + pub port: Option, + pub user: Option, + pub identity_file: Option, + pub proxy_jump: Option, + pub host_key_alias: Option, +} + +/// A ready-to-use session entry derived from the SSH config. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SshConfigEntry { + pub alias: String, + pub host: String, + pub port: u16, + pub user: String, + pub identity_file: Option, + pub proxy_jump: Option, + pub hops: Vec, + pub host_key_alias: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SshConfigHop { + pub host: String, + pub port: u16, + pub user: String, + pub is_target: bool, +} + +/// The fully parsed SSH config. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SshConfig { + pub hosts: Vec, +} + +impl SshConfig { + /// Reads and parses the default user SSH config (`~/.ssh/config`). + pub fn load_default() -> AppResult { + let path = default_config_path(); + Self::load_from(&path) + } + + /// Parses one or more SSH config files, following `Include` directives. + pub fn load_from(path: &Path) -> AppResult { + let mut visited = HashSet::new(); + let mut hosts = Vec::new(); + let mut state = ParseState::default(); + parse_file(path, true, &mut visited, &mut hosts, &mut state)?; + finish_parse(&mut hosts, &mut state); + Ok(SshConfig { hosts }) + } + + /// Resolves a host alias into a complete entry with ProxyJump hops. + pub fn resolve(&self, alias: &str) -> AppResult { + let resolved = self.resolve_options(alias); + let host_name = resolved.host_name.unwrap_or_else(|| alias.to_string()); + let port = resolved.port.unwrap_or(22); + let user = resolved.user.unwrap_or_else(whoami::username); + let identity_file = resolved.identity_file; + // `ProxyJump none` explicitly disables a value that may have matched + // earlier (for example from `Host *`). It is not a host named `none`. + let proxy_jump = resolved + .proxy_jump + .clone() + .filter(|value| !value.eq_ignore_ascii_case("none")); + + let mut hops = Vec::new(); + if let Some(ref pj) = proxy_jump { + for jump_spec in pj.split(',') { + let jump_spec = jump_spec.trim(); + if jump_spec.is_empty() { + continue; + } + // Parse user@host:port syntax from ProxyJump directives. + let (jump_user_spec, jump_host_spec, jump_port_spec) = parse_jump_spec(jump_spec)?; + let jump_alias = &jump_host_spec; + let jump_resolved = self.resolve_options(jump_alias); + let jump_host = jump_resolved + .host_name + .unwrap_or_else(|| jump_alias.to_string()); + let jump_port = jump_port_spec.or(jump_resolved.port).unwrap_or(22); + let jump_user = jump_user_spec + .or(jump_resolved.user) + .unwrap_or_else(whoami::username); + hops.push(SshConfigHop { + host: jump_host, + port: jump_port, + user: jump_user, + is_target: false, + }); + } + } + hops.push(SshConfigHop { + host: host_name.clone(), + port, + user: user.clone(), + is_target: true, + }); + + Ok(SshConfigEntry { + alias: alias.to_string(), + host: host_name, + port, + user, + identity_file, + proxy_jump, + hops, + host_key_alias: resolved.host_key_alias, + }) + } + + /// Returns concrete host aliases (non-wildcard), deduplicated. + pub fn list_hosts(&self) -> Vec { + let mut seen = HashSet::new(); + self.hosts + .iter() + .flat_map(|h| &h.patterns) + .filter(|p| !p.contains('*') && !p.contains('?') && !p.starts_with('!')) + .filter(|p| seen.insert((*p).clone())) + .map(|p| p.to_string()) + .collect() + } + + /// Resolves options for a given alias using first-match-wins. + fn resolve_options(&self, alias: &str) -> ResolvedOptions { + let mut resolved = ResolvedOptions::default(); + for host in &self.hosts { + if pattern_matches(&host.patterns, alias) { + if resolved.host_name.is_none() { + resolved.host_name = host.host_name.clone(); + } + if resolved.port.is_none() { + resolved.port = host.port; + } + if resolved.user.is_none() { + resolved.user = host.user.clone(); + } + if resolved.identity_file.is_none() { + resolved.identity_file = host.identity_file.clone(); + } + if resolved.proxy_jump.is_none() { + resolved.proxy_jump = host.proxy_jump.clone(); + } + if resolved.host_key_alias.is_none() { + resolved.host_key_alias = host.host_key_alias.clone(); + } + } + } + resolved + } + + /// Converts all concrete host aliases into entries. + pub fn to_entries(&self) -> AppResult> { + self.list_hosts() + .iter() + .map(|alias| self.resolve(alias)) + .collect() + } +} + +#[derive(Debug, Default)] +struct ResolvedOptions { + host_name: Option, + port: Option, + user: Option, + identity_file: Option, + proxy_jump: Option, + host_key_alias: Option, +} + +fn pattern_matches(patterns: &[String], alias: &str) -> bool { + let mut matched = false; + for pattern in patterns { + let pat = pattern.as_str(); + if let Some(neg) = pat.strip_prefix('!') { + if glob_match(neg, alias) { + return false; + } + } else if glob_match(pat, alias) { + matched = true; + } + } + matched +} + +fn glob_match(pattern: &str, text: &str) -> bool { + let p: Vec = pattern.chars().collect(); + let t: Vec = text.chars().collect(); + glob_match_inner(&p, &t) +} + +fn glob_match_inner(p: &[char], t: &[char]) -> bool { + if p.is_empty() { + return t.is_empty(); + } + match p[0] { + '*' => { + for i in 0..=t.len() { + if glob_match_inner(&p[1..], &t[i..]) { + return true; + } + } + false + } + '?' => { + if t.is_empty() { + false + } else { + glob_match_inner(&p[1..], &t[1..]) + } + } + c => { + if t.is_empty() || t[0] != c { + false + } else { + glob_match_inner(&p[1..], &t[1..]) + } + } + } +} + +#[derive(Default)] +struct ParseState { + current: Option, + global: SshConfigHost, + global_emitted: bool, +} + +fn parse_file( + path: &Path, + required: bool, + visited: &mut HashSet, + hosts: &mut Vec, + state: &mut ParseState, +) -> AppResult<()> { + let canonical = match fs::canonicalize(path) { + Ok(c) => c, + Err(error) if required => { + return Err(AppError::Config(format!( + "SSH config file not found or cannot be accessed at {}: {error}", + path.display() + ))); + } + Err(_) => return Ok(()), + }; + if !visited.insert(canonical.clone()) { + return Ok(()); + } + + let contents = fs::read_to_string(&canonical) + .map_err(|e| AppError::Config(format!("cannot read {}: {e}", canonical.display())))?; + + parse_string_into(&contents, &canonical, visited, hosts, state) +} + +#[cfg(test)] +fn parse_string( + contents: &str, + config_path: &Path, + visited: &mut HashSet, + hosts: &mut Vec, +) -> AppResult<()> { + let mut state = ParseState::default(); + parse_string_into(contents, config_path, visited, hosts, &mut state)?; + finish_parse(hosts, &mut state); + Ok(()) +} + +fn parse_string_into( + contents: &str, + config_path: &Path, + visited: &mut HashSet, + hosts: &mut Vec, + state: &mut ParseState, +) -> AppResult<()> { + // Relative Include paths resolve from the directory containing the config + // file, not from the config file path itself (matching OpenSSH behavior). + let base_dir = config_path.parent().unwrap_or_else(|| Path::new(".")); + for raw_line in contents.lines() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let (keyword, value) = match split_kv(line) { + Some(pair) => pair, + None => continue, + }; + + let kw_lower = keyword.to_lowercase(); + + match kw_lower.as_str() { + "host" => { + if let Some(mut block) = state.current.take() { + block.name = derive_display_name(&block.patterns); + hosts.push(block); + } + let patterns: Vec = + value.split_whitespace().map(|s| s.to_string()).collect(); + // Global directives are an implicit leading `Host *` block. + // Keeping it in lexical order is what gives OpenSSH its + // first-value-wins behaviour when resolving later blocks. + if !state.global_emitted && has_options(&state.global) { + let mut global = state.global.clone(); + global.patterns = vec!["*".to_string()]; + global.name = "*".to_string(); + hosts.push(global); + state.global_emitted = true; + } + state.current = Some(SshConfigHost { + patterns, + ..Default::default() + }); + } + "hostname" => { + if let Some(ref mut block) = state.current { + set_if_missing(&mut block.host_name, value.to_string()); + } else { + set_if_missing(&mut state.global.host_name, value.to_string()); + } + } + "port" => { + let port = value.parse::().map_err(|_| { + AppError::Config(format!( + "Invalid Port '{value}' in {}", + config_path.display() + )) + })?; + if let Some(ref mut block) = state.current { + set_if_missing(&mut block.port, port); + } else { + set_if_missing(&mut state.global.port, port); + } + } + "user" => { + if let Some(ref mut block) = state.current { + set_if_missing(&mut block.user, value.to_string()); + } else { + set_if_missing(&mut state.global.user, value.to_string()); + } + } + "identityfile" => { + if let Some(ref mut block) = state.current { + set_if_missing(&mut block.identity_file, expand_tilde(value)); + } else { + set_if_missing(&mut state.global.identity_file, expand_tilde(value)); + } + } + "proxyjump" => { + if let Some(ref mut block) = state.current { + set_if_missing(&mut block.proxy_jump, value.to_string()); + } else { + set_if_missing(&mut state.global.proxy_jump, value.to_string()); + } + } + "hostkeyalias" => { + if let Some(ref mut block) = state.current { + set_if_missing(&mut block.host_key_alias, value.to_string()); + } else { + set_if_missing(&mut state.global.host_key_alias, value.to_string()); + } + } + "include" => { + for pattern in value.split_whitespace() { + let expanded = expand_tilde(pattern); + let include_path = if Path::new(&expanded).is_absolute() { + PathBuf::from(&expanded) + } else { + base_dir.join(&expanded) + }; + for matched in glob_paths(&include_path) { + parse_file(&matched, false, visited, hosts, state)?; + } + } + } + _ => {} + } + } + + Ok(()) +} + +fn set_if_missing(slot: &mut Option, value: T) { + if slot.is_none() { + *slot = Some(value); + } +} + +fn has_options(block: &SshConfigHost) -> bool { + block.host_name.is_some() + || block.port.is_some() + || block.user.is_some() + || block.identity_file.is_some() + || block.proxy_jump.is_some() + || block.host_key_alias.is_some() +} + +fn finish_parse(hosts: &mut Vec, state: &mut ParseState) { + if !state.global_emitted && has_options(&state.global) { + let mut global = state.global.clone(); + global.patterns = vec!["*".to_string()]; + global.name = "*".to_string(); + hosts.push(global); + state.global_emitted = true; + } + if let Some(mut block) = state.current.take() { + block.name = derive_display_name(&block.patterns); + hosts.push(block); + } +} + +fn split_kv(line: &str) -> Option<(&str, &str)> { + let mut iter = line.splitn(2, char::is_whitespace); + let keyword = iter.next()?.trim(); + let value = iter.next()?.trim(); + if keyword.is_empty() || value.is_empty() { + return None; + } + Some((keyword, value)) +} + +/// Parses a ProxyJump specification into (user, host, port) components. +/// Supports: `bastion`, `user@bastion`, `bastion:2222`, `user@bastion:2222`. +fn parse_jump_spec(spec: &str) -> AppResult<(Option, String, Option)> { + let spec = spec.trim(); + if spec.is_empty() { + return Err(AppError::Config( + "ProxyJump contains an empty hop".to_string(), + )); + } + let mut remaining = spec; + let mut user = None; + let mut port = None; + + // Extract user if present: user@host[:port] + if let Some(at_pos) = remaining.find('@') { + if at_pos == 0 || remaining[at_pos + 1..].contains('@') { + return Err(AppError::Config(format!("Invalid ProxyJump hop '{spec}'"))); + } + user = Some(remaining[..at_pos].to_string()); + remaining = &remaining[at_pos + 1..]; + } + + // Extract port if present: host:port + if let Some(colon_pos) = remaining.rfind(':') { + let port_part = &remaining[colon_pos + 1..]; + port = Some( + port_part + .parse::() + .map_err(|_| AppError::Config(format!("Invalid ProxyJump port in '{spec}'")))?, + ); + remaining = &remaining[..colon_pos]; + } + + if remaining.is_empty() { + return Err(AppError::Config(format!("Invalid ProxyJump hop '{spec}'"))); + } + + Ok((user, remaining.to_string(), port)) +} + +fn derive_display_name(patterns: &[String]) -> String { + for p in patterns { + if !p.contains('*') && !p.contains('?') && !p.starts_with('!') { + return p.clone(); + } + } + patterns + .iter() + .filter(|p| !p.starts_with('!')) + .cloned() + .collect::>() + .join(" ") +} + +fn expand_tilde(path: &str) -> String { + if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest).to_string_lossy().into_owned(); + } + } else if path == "~" { + if let Some(home) = dirs::home_dir() { + return home.to_string_lossy().into_owned(); + } + } + path.to_string() +} + +fn glob_paths(pattern: &Path) -> Vec { + let pattern_str = pattern.to_string_lossy(); + if !pattern_str.contains('*') && !pattern_str.contains('?') { + if pattern.exists() { + return vec![pattern.to_path_buf()]; + } + return vec![]; + } + + let parent = pattern.parent(); + let file_name = pattern.file_name(); + + match (parent, file_name) { + (Some(parent), Some(file_name)) => { + let pattern_str = file_name.to_string_lossy(); + let mut results = Vec::new(); + if let Ok(entries) = fs::read_dir(parent) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if glob_match(&pattern_str, &name) { + results.push(entry.path()); + } + } + } + results.sort(); + results + } + _ => vec![], + } +} + +fn default_config_path() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".ssh") + .join("config") +} + +/// Converts an SshConfigEntry into a SavedConnection for nyaterm. +/// Uses `agent` auth by default. When an identity file is present, we still +/// use `agent` because the key file would need to be registered in nyaterm's +/// key store (which requires reading the file content). The identity file path +/// is noted as a manual setup requirement rather than claiming the key was +/// imported. +fn entry_to_saved_connection( + entry: &SshConfigEntry, + proxy_jump_id: Option, +) -> SavedConnection { + let description = if entry.proxy_jump.is_some() { + format!( + "Imported from ~/.ssh/config (ProxyJump: {}{})", + entry.proxy_jump.as_ref().unwrap(), + if entry.identity_file.is_some() { + format!( + ", IdentityFile recognized (configure manually): {}", + entry.identity_file.as_ref().unwrap() + ) + } else { + String::new() + } + ) + } else if entry.identity_file.is_some() { + format!( + "Imported from ~/.ssh/config (IdentityFile recognized; configure manually: {})", + entry.identity_file.as_ref().unwrap() + ) + } else { + "Imported from ~/.ssh/config".to_string() + }; + + SavedConnection { + id: uuid::Uuid::new_v4().to_string(), + name: entry.alias.clone(), + config: ConnectionType::Ssh { + host: entry.host.clone(), + port: entry.port, + username: entry.user.clone(), + backspace_mode: "del".to_string(), + x11_forwarding: false, + auth_agent_endpoint: None, + legacy_agent_forwarding: None, + agent_forwarding_config: None, + encoding: String::new(), + dynamic_tab_title: false, + }, + group_id: None, + description: Some(description), + sort_order: 0, + icon: None, + icon_auto_detect: None, + auth: Some(ConnectionAuth { + mode: "agent".to_string(), + password_id: None, + password: None, + key_id: None, + otp_id: None, + auto_fill_otp: false, + has_password: false, + }), + network: proxy_jump_id.map(|id| crate::config::ConnectionNetwork { + proxy_id: None, + proxy_jump_id: Some(id), + }), + post_login: None, + recording: None, + ssh_algorithms: None, + ssh_profile: Default::default(), + terminal_type: None, + sftp: Default::default(), + asset: None, + created_at_ms: None, + updated_at_ms: None, + last_used_at_ms: None, + } +} + +/// Imports SSH config hosts as saved connections, skipping existing names. +/// Each ProxyJump list is materialized in reverse linkage order because the +/// runtime recursively follows `proxy_jump_id` from a target to the previous +/// hop. Later hops are synthetic connections so a plain `Host jump2` remains +/// a direct connection even when a target reaches it via `jump1`. +pub fn import_ssh_config_connections(app: &tauri::AppHandle) -> AppResult { + let config = SshConfig::load_default()?; + let mut cfg = crate::config::load_config(app)?; + let connections = build_imported_connections(&config, &cfg.connections)?; + let count = connections.len(); + cfg.connections.extend(connections); + + if count > 0 { + crate::config::save_config(app, &cfg)?; + } + + Ok(count) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn glob_matches_basic_patterns() { + assert!(glob_match("web-*", "web-prod")); + assert!(glob_match("*", "anything")); + assert!(glob_match("server?", "server1")); + assert!(!glob_match("server?", "server12")); + assert!(glob_match("exact", "exact")); + assert!(!glob_match("exact", "other")); + } + + #[test] + fn pattern_matches_handles_negation() { + let patterns = vec!["web-*".to_string(), "!web-old".to_string()]; + assert!(pattern_matches(&patterns, "web-prod")); + assert!(!pattern_matches(&patterns, "web-old")); + } + + #[test] + fn parse_simple_config() { + let config = r#" + Host prod + HostName prod.example.com + Port 2222 + User admin + IdentityFile ~/.ssh/prod_key + + Host staging + HostName staging.example.com + User deploy + "#; + + let mut hosts = Vec::new(); + let mut visited = HashSet::new(); + parse_string( + config, + Path::new("/tmp/test/config"), + &mut visited, + &mut hosts, + ) + .unwrap(); + + assert_eq!(hosts.len(), 2); + assert_eq!(hosts[0].name, "prod"); + assert_eq!(hosts[0].host_name.as_deref(), Some("prod.example.com")); + assert_eq!(hosts[0].port, Some(2222)); + assert_eq!(hosts[0].user.as_deref(), Some("admin")); + } + + #[test] + fn resolve_proxy_jump_chain() { + let config = r#" + Host jump1 + HostName jump1.example.com + User juser + + Host jump2 + HostName jump2.example.com + Port 2222 + User juser2 + + Host target + HostName 10.0.0.42 + User root + ProxyJump jump1,jump2 + "#; + + let mut hosts = Vec::new(); + let mut visited = HashSet::new(); + parse_string( + config, + Path::new("/tmp/chain/config"), + &mut visited, + &mut hosts, + ) + .unwrap(); + + let ssh_config = SshConfig { hosts }; + let resolved = ssh_config.resolve("target").unwrap(); + + assert_eq!(resolved.hops.len(), 3); + assert_eq!(resolved.hops[0].host, "jump1.example.com"); + assert_eq!(resolved.hops[0].user, "juser"); + assert!(!resolved.hops[0].is_target); + + assert_eq!(resolved.hops[1].host, "jump2.example.com"); + assert_eq!(resolved.hops[1].port, 2222); + assert!(!resolved.hops[1].is_target); + + assert_eq!(resolved.hops[2].host, "10.0.0.42"); + assert_eq!(resolved.hops[2].user, "root"); + assert!(resolved.hops[2].is_target); + } + + #[test] + fn resolve_first_match_wins() { + // OpenSSH uses first-match-wins: the first value found for each + // option is the one used. With `Host *` before `Host prod`, the + // `User defaultuser` from `*` wins because it matches first. + // To have `User admin` win, the `Host prod` block must come first. + let config = r#" + Host prod + HostName prod.example.com + User admin + + Host * + User defaultuser + Port 2222 + "#; + + let mut hosts = Vec::new(); + let mut visited = HashSet::new(); + parse_string( + config, + Path::new("/tmp/resolve/config"), + &mut visited, + &mut hosts, + ) + .unwrap(); + + let ssh_config = SshConfig { hosts }; + let resolved = ssh_config.resolve("prod").unwrap(); + + // `Host prod` matches first, so `User admin` wins. + assert_eq!(resolved.user, "admin"); + // `Host *` provides Port 2222 because `prod` doesn't specify one. + assert_eq!(resolved.port, 2222); + assert_eq!(resolved.host, "prod.example.com"); + } + + #[test] + fn list_hosts_skips_wildcards() { + let config = r#" + Host * + User default + + Host web-* + User webuser + + Host prod + HostName prod.example.com + "#; + + let mut hosts = Vec::new(); + let mut visited = HashSet::new(); + parse_string( + config, + Path::new("/tmp/list/config"), + &mut visited, + &mut hosts, + ) + .unwrap(); + + let ssh_config = SshConfig { hosts }; + let host_list = ssh_config.list_hosts(); + assert!(host_list.contains(&"prod".to_string())); + assert!(!host_list.contains(&"*".to_string())); + assert!(!host_list.contains(&"web-*".to_string())); + } + + #[test] + fn relative_include_resolves_from_ssh_dir() { + // Simulate a config at ~/.ssh/config with Include conf.d/*.conf + // The base_dir should be ~/.ssh/ (parent of config), not ~/.ssh/config/ + let config_path = Path::new("/tmp/ssh_test/config"); + // We can't test actual file resolution without creating files, + // but we can verify that the base_dir is derived correctly. + let base_dir = config_path.parent().unwrap(); + assert_eq!(base_dir, Path::new("/tmp/ssh_test")); + // conf.d/*.conf would resolve to /tmp/ssh_test/conf.d/*.conf (correct) + // not /tmp/ssh_test/config/conf.d/*.conf (wrong - old behavior) + } + + #[test] + fn parse_jump_spec_extracts_components() { + // Bare alias + let (user, host, port) = parse_jump_spec("bastion").unwrap(); + assert_eq!(user, None); + assert_eq!(host, "bastion"); + assert_eq!(port, None); + + // user@host + let (user, host, port) = parse_jump_spec("alice@bastion").unwrap(); + assert_eq!(user.as_deref(), Some("alice")); + assert_eq!(host, "bastion"); + assert_eq!(port, None); + + // host:port + let (user, host, port) = parse_jump_spec("bastion:2222").unwrap(); + assert_eq!(user, None); + assert_eq!(host, "bastion"); + assert_eq!(port, Some(2222)); + + // user@host:port + let (user, host, port) = parse_jump_spec("alice@bastion:2222").unwrap(); + assert_eq!(user.as_deref(), Some("alice")); + assert_eq!(host, "bastion"); + assert_eq!(port, Some(2222)); + } + + #[test] + fn list_hosts_deduplicates() { + let config = r#" + Host prod + User admin + + Host prod + Port 2222 + + Host staging + HostName staging.example.com + "#; + + let mut hosts = Vec::new(); + let mut visited = HashSet::new(); + parse_string( + config, + Path::new("/tmp/dedup/config"), + &mut visited, + &mut hosts, + ) + .unwrap(); + + let ssh_config = SshConfig { hosts }; + let host_list = ssh_config.list_hosts(); + // "prod" appears in two blocks but should be listed once. + assert_eq!(host_list.iter().filter(|h| *h == "prod").count(), 1); + assert_eq!(host_list.len(), 2); + } + + #[test] + fn global_options_before_first_host() { + let config = r#" + User deploy + Port 2222 + + Host prod + HostName prod.example.com + "#; + + let mut hosts = Vec::new(); + let mut visited = HashSet::new(); + parse_string( + config, + Path::new("/tmp/global/config"), + &mut visited, + &mut hosts, + ) + .unwrap(); + + let ssh_config = SshConfig { hosts }; + let resolved = ssh_config.resolve("prod").unwrap(); + // Global User and Port should apply to all hosts. + assert_eq!(resolved.user, "deploy"); + assert_eq!(resolved.port, 2222); + assert_eq!(resolved.host, "prod.example.com"); + } + + #[test] + fn proxy_jump_with_user_at_host_port() { + let config = r#" + Host bastion + HostName bastion.example.com + + Host target + HostName 10.0.0.42 + ProxyJump alice@bastion:2222 + "#; + + let mut hosts = Vec::new(); + let mut visited = HashSet::new(); + parse_string( + config, + Path::new("/tmp/jumpuser/config"), + &mut visited, + &mut hosts, + ) + .unwrap(); + + let ssh_config = SshConfig { hosts }; + let resolved = ssh_config.resolve("target").unwrap(); + + assert_eq!(resolved.hops.len(), 2); + assert_eq!(resolved.hops[0].host, "bastion.example.com"); + assert_eq!(resolved.hops[0].user, "alice"); + assert_eq!(resolved.hops[0].port, 2222); + assert!(!resolved.hops[0].is_target); + } + + #[test] + fn first_value_wins_when_wildcard_precedes_specific_host() { + let config = r#" + Host * + User default + + Host prod + User admin + "#; + let ssh_config = parse_test_config(config); + + assert_eq!(ssh_config.resolve("prod").unwrap().user, "default"); + } + + #[test] + fn global_option_precedes_and_wins_over_host_option() { + // This matches `ssh -G prod -F config`: global directives occur + // before Host blocks and are therefore the first value obtained. + let config = r#" + User global + + Host prod + User specific + "#; + let ssh_config = parse_test_config(config); + + assert_eq!(ssh_config.resolve("prod").unwrap().user, "global"); + } + + #[test] + fn identity_file_is_manual_agent_setup_not_key_import() { + let ssh_config = parse_test_config("Host prod\n IdentityFile ~/.ssh/id_ed25519\n"); + let entry = ssh_config.resolve("prod").unwrap(); + let connection = entry_to_saved_connection(&entry, None); + + assert_eq!(connection.auth.as_ref().unwrap().mode, "agent"); + assert!(connection.auth.as_ref().unwrap().key_id.is_none()); + assert!( + connection + .description + .as_deref() + .unwrap() + .contains("configure manually") + ); + } + + #[test] + fn imported_connections_materialize_two_hop_proxy_jump_in_runtime_order() { + let ssh_config = parse_test_config( + r#" + Host jump1 + HostName jump1.example.com + + Host jump2 + HostName jump2.internal + + Host target + HostName target.internal + ProxyJump jump1,jump2 + "#, + ); + let connections = build_imported_connections(&ssh_config, &[]).unwrap(); + let by_name = connections + .iter() + .map(|connection| (connection.name.as_str(), connection)) + .collect::>(); + let jump1 = by_name["jump1"]; + let jump2 = by_name["jump2"]; + let jump2_via_jump1 = by_name["jump2 (ProxyJump via: jump1)"]; + let target = by_name["target"]; + + assert!(jump1.network.is_none()); + assert!(jump2.network.is_none()); + assert_eq!( + jump2_via_jump1 + .network + .as_ref() + .and_then(|network| network.proxy_jump_id.as_deref()), + Some(jump1.id.as_str()) + ); + assert_eq!( + target + .network + .as_ref() + .and_then(|network| network.proxy_jump_id.as_deref()), + Some(jump2_via_jump1.id.as_str()) + ); + } + + #[test] + fn imported_connections_deduplicate_repeated_host_aliases() { + let ssh_config = parse_test_config( + r#" + Host foo + User admin + Host foo + Port 2222 + "#, + ); + let connections = build_imported_connections(&ssh_config, &[]).unwrap(); + + assert_eq!( + connections + .iter() + .filter(|connection| connection.name == "foo") + .count(), + 1 + ); + let ConnectionType::Ssh { port, .. } = &connections[0].config else { + panic!("SSH connection expected"); + }; + assert_eq!(*port, 2222); + } + + #[test] + fn imported_connections_materialize_three_hop_proxy_jump_in_runtime_order() { + let ssh_config = parse_test_config( + r#" + Host jump1 + Host jump2 + Host jump3 + Host target + ProxyJump jump1,jump2,jump3 + "#, + ); + let connections = build_imported_connections(&ssh_config, &[]).unwrap(); + let by_name = connections + .iter() + .map(|connection| (connection.name.as_str(), connection)) + .collect::>(); + + assert!(by_name["jump1"].network.is_none()); + assert!(by_name["jump2"].network.is_none()); + assert!(by_name["jump3"].network.is_none()); + + let jump2 = by_name["jump2 (ProxyJump via: jump1)"]; + let jump3 = by_name["jump3 (ProxyJump via: jump1,jump2)"]; + assert_eq!( + jump2 + .network + .as_ref() + .and_then(|network| network.proxy_jump_id.as_deref()), + Some(by_name["jump1"].id.as_str()) + ); + assert_eq!( + jump3 + .network + .as_ref() + .and_then(|network| network.proxy_jump_id.as_deref()), + Some(jump2.id.as_str()) + ); + assert_eq!( + by_name["target"] + .network + .as_ref() + .and_then(|network| network.proxy_jump_id.as_deref()), + Some(jump3.id.as_str()) + ); + } + + #[test] + fn existing_direct_jump_alias_does_not_block_multi_hop_import() { + let direct_config = parse_test_config("Host jump2\n HostName jump2.example.com\n"); + let existing = build_imported_connections(&direct_config, &[]) + .unwrap() + .pop() + .expect("direct jump2 connection"); + assert!(existing.network.is_none()); + + let ssh_config = parse_test_config( + r#" + Host jump1 + Host jump2 + Host target + ProxyJump jump1,jump2 + "#, + ); + let connections = build_imported_connections(&ssh_config, &[existing]).unwrap(); + let by_name = connections + .iter() + .map(|connection| (connection.name.as_str(), connection)) + .collect::>(); + + assert!(!by_name.contains_key("jump2")); + let jump1 = by_name["jump1"]; + let routed_jump2 = by_name["jump2 (ProxyJump via: jump1)"]; + assert_eq!( + routed_jump2 + .network + .as_ref() + .and_then(|network| network.proxy_jump_id.as_deref()), + Some(jump1.id.as_str()) + ); + } + + #[test] + fn imported_proxy_jump_override_preserves_user_and_port_in_distinct_connection() { + let ssh_config = parse_test_config( + r#" + Host bastion + HostName bastion.example.com + User defaultuser + Port 22 + + Host target + ProxyJump alice@bastion:2222 + "#, + ); + let connections = build_imported_connections(&ssh_config, &[]).unwrap(); + let jump = connections + .iter() + .find(|connection| connection.name == "bastion (ProxyJump: alice@bastion:2222)") + .expect("override jump connection"); + let target = connections + .iter() + .find(|connection| connection.name == "target") + .expect("target connection"); + let ConnectionType::Ssh { + host, + port, + username, + .. + } = &jump.config + else { + panic!("SSH connection expected"); + }; + + assert_eq!(host, "bastion.example.com"); + assert_eq!(*port, 2222); + assert_eq!(username, "alice"); + assert_eq!( + target + .network + .as_ref() + .and_then(|network| network.proxy_jump_id.as_deref()), + Some(jump.id.as_str()) + ); + } + + #[test] + fn load_from_follows_relative_recursive_includes_and_ignores_cycles() { + let root = test_temp_dir("includes"); + let config = root.join("config"); + let include_dir = root.join("conf.d"); + fs::create_dir_all(&include_dir).unwrap(); + fs::write(&config, "Include conf.d/*.conf\n").unwrap(); + fs::write( + include_dir.join("work.conf"), + "Include nested.conf\nHost work\n User dev\n", + ) + .unwrap(); + fs::write( + include_dir.join("nested.conf"), + "Include ../config\nHost nested\n Port 2200\n", + ) + .unwrap(); + + let parsed = SshConfig::load_from(&config).unwrap(); + assert_eq!(parsed.resolve("work").unwrap().user, "dev"); + assert_eq!(parsed.resolve("nested").unwrap().port, 2200); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn missing_root_config_returns_an_actionable_error() { + let missing = test_temp_dir("missing").join("config"); + let error = SshConfig::load_from(&missing).unwrap_err(); + assert!(error.to_string().contains("SSH config file not found")); + } + + fn parse_test_config(contents: &str) -> SshConfig { + let mut hosts = Vec::new(); + let mut visited = HashSet::new(); + parse_string( + contents, + Path::new("/tmp/ssh_config_test/config"), + &mut visited, + &mut hosts, + ) + .unwrap(); + SshConfig { hosts } + } + + fn test_temp_dir(name: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!( + "nyaterm_ssh_config_{name}_{}", + uuid::Uuid::new_v4() + )); + fs::create_dir_all(&path).unwrap(); + path + } +} + +#[derive(Debug, Clone)] +struct ImportNode { + key: String, + name: String, + entry: SshConfigEntry, +} + +/// Builds the connection graph before it is persisted. Kept separate from the +/// Tauri/storage boundary so the exact runtime graph can be tested. +fn build_imported_connections( + config: &SshConfig, + existing: &[SavedConnection], +) -> AppResult> { + use std::collections::HashMap; + + let entries = config.to_entries()?; + let mut nodes: HashMap = HashMap::new(); + let mut links: HashMap = HashMap::new(); + + for entry in &entries { + let target = regular_import_node(entry.clone()); + nodes.entry(target.key.clone()).or_insert(target.clone()); + + let Some(proxy_jump) = entry.proxy_jump.as_deref() else { + continue; + }; + + let mut previous: Option = None; + let mut prior_specs = Vec::new(); + for raw_spec in proxy_jump.split(',') { + let jump = jump_import_node(config, raw_spec, &prior_specs)?; + nodes.entry(jump.key.clone()).or_insert(jump.clone()); + if let Some(previous) = previous { + set_import_link(&mut links, &jump.key, &previous.key)?; + } + prior_specs.push(raw_spec.trim().to_string()); + previous = Some(jump); + } + if let Some(last_jump) = previous { + set_import_link(&mut links, &target.key, &last_jump.key)?; + } + } + + let existing_by_name: HashMap<&str, &SavedConnection> = existing + .iter() + .map(|connection| (connection.name.as_str(), connection)) + .collect(); + let mut ids = HashMap::new(); + let mut ordered_nodes: Vec<_> = nodes.into_values().collect(); + ordered_nodes.sort_by(|a, b| a.name.cmp(&b.name)); + + for node in &ordered_nodes { + if let Some(existing) = existing_by_name.get(node.name.as_str()) { + ids.insert(node.key.clone(), existing.id.clone()); + } else { + ids.insert(node.key.clone(), uuid::Uuid::new_v4().to_string()); + } + } + + let mut imported = Vec::new(); + for node in ordered_nodes { + let expected_jump_id = links.get(&node.key).map(|key| { + ids.get(key) + .expect("all ProxyJump links have a materialized connection") + .clone() + }); + + if let Some(existing) = existing_by_name.get(node.name.as_str()) { + let existing_jump_id = existing + .network + .as_ref() + .and_then(|network| network.proxy_jump_id.as_ref()); + if expected_jump_id.as_deref() != existing_jump_id.map(String::as_str) { + return Err(AppError::Config(format!( + "Cannot safely import ProxyJump route: existing connection '{}' has a different jump host", + node.name + ))); + } + continue; + } + + let mut connection = entry_to_saved_connection(&node.entry, expected_jump_id); + connection.id = ids.get(&node.key).expect("generated ID exists").clone(); + connection.name = node.name; + imported.push(connection); + } + + Ok(imported) +} + +fn set_import_link( + links: &mut std::collections::HashMap, + node: &str, + previous_hop: &str, +) -> AppResult<()> { + if let Some(existing) = links.insert(node.to_string(), previous_hop.to_string()) { + if existing != previous_hop { + return Err(AppError::Config(format!( + "ProxyJump host '{node}' is used with conflicting preceding hops ('{existing}' and '{previous_hop}')" + ))); + } + } + Ok(()) +} + +fn regular_import_node(entry: SshConfigEntry) -> ImportNode { + ImportNode { + key: format!("alias:{}", entry.alias), + name: entry.alias.clone(), + entry, + } +} + +fn jump_import_node( + config: &SshConfig, + raw_spec: &str, + prior_specs: &[String], +) -> AppResult { + let (user_override, host_alias, port_override) = parse_jump_spec(raw_spec)?; + let mut entry = config.resolve(&host_alias)?; + let has_override = user_override.is_some() || port_override.is_some(); + if let Some(user) = user_override { + entry.user = user; + } + if let Some(port) = port_override { + entry.port = port; + } + + // A second-or-later hop has route-specific semantics: it must reach the + // previous hop first. Never attach that context to the regular alias, + // otherwise opening `jump2` directly would incorrectly transit `jump1`. + if has_override || !prior_specs.is_empty() { + let raw_spec = raw_spec.trim(); + let route_prefix = prior_specs.join(","); + let name = if route_prefix.is_empty() { + format!("{} (ProxyJump: {raw_spec})", host_alias) + } else if raw_spec == host_alias { + format!("{} (ProxyJump via: {route_prefix})", host_alias) + } else { + format!( + "{} (ProxyJump via: {route_prefix}; spec: {raw_spec})", + host_alias + ) + }; + Ok(ImportNode { + key: format!("jump:{raw_spec}:via:{route_prefix}"), + name, + entry, + }) + } else { + Ok(regular_import_node(entry)) + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3d0f5347..a803fd7c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -167,6 +167,8 @@ pub fn run() { cmd::mcp::notify_mcp_session_restore_complete, cmd::mcp::set_external_mcp_enabled, cmd::mcp::respond_external_mcp_approval, + cmd::mcp::report_mcp_active_session, + cmd::mcp::respond_mcp_session_open, cmd::mcp::get_external_mcp_client_configs, cmd::ai::detect_claude_code_cli, cmd::ai::get_claude_code_account_status, @@ -280,6 +282,7 @@ pub fn run() { cmd::sftp::create_remote_file, cmd::sftp::create_remote_dir, cmd::sftp::create_remote_symlink, + cmd::sftp::update_remote_symlink_target, cmd::sftp::chmod_remote_file, cmd::sftp::update_remote_file_attributes, cmd::sftp::download_remote_directory, @@ -350,6 +353,10 @@ pub fn run() { cmd::translate::translate_text, cmd::importer::import_sessions, cmd::importer::import_termius_sessions, + cmd::ssh_config::list_ssh_config_hosts, + cmd::ssh_config::get_ssh_config, + cmd::ssh_config::resolve_ssh_host, + cmd::ssh_config::import_ssh_config_hosts, cmd::backup::export_config, cmd::backup::import_config, cmd::stats::get_remote_stats, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 941b851d..7f4934b9 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "NyaTerm", - "version": "1.2.5", + "version": "1.2.6", "identifier": "com.kang.nyaterm", "build": { "beforeDevCommand": "pnpm build:mcp-sidecar && pnpm dev", diff --git a/src-tauri/vendor/russh-sftp/src/client/rawsession.rs b/src-tauri/vendor/russh-sftp/src/client/rawsession.rs index 16c9f4d8..cfd7f771 100644 --- a/src-tauri/vendor/russh-sftp/src/client/rawsession.rs +++ b/src-tauri/vendor/russh-sftp/src/client/rawsession.rs @@ -935,6 +935,25 @@ impl RawSftpSession { ReadLink { id, path: path.into(), + path_bytes: None, + } + .into(), + ) + .await?; + + into_with_status!(result, Name) + } + + pub async fn readlink_bytes(&self, path_bytes: Vec) -> SftpResult { + let id = self.use_next_id(); + let path = String::from_utf8_lossy(&path_bytes).into_owned(); + let result = self + .request( + Some(id), + ReadLink { + id, + path, + path_bytes: Some(path_bytes), } .into(), ) @@ -956,6 +975,8 @@ impl RawSftpSession { id, linkpath: path.into(), targetpath: target.into(), + linkpath_bytes: None, + targetpath_bytes: None, } .into(), ) @@ -977,6 +998,33 @@ impl RawSftpSession { id, linkpath: target.into(), targetpath: link.into(), + linkpath_bytes: None, + targetpath_bytes: None, + } + .into(), + ) + .await?; + + into_status!(result) + } + + pub async fn symlink_openssh_bytes( + &self, + target_bytes: Vec, + link_bytes: Vec, + ) -> SftpResult { + let id = self.use_next_id(); + let linkpath = String::from_utf8_lossy(&target_bytes).into_owned(); + let targetpath = String::from_utf8_lossy(&link_bytes).into_owned(); + let result = self + .request( + Some(id), + Symlink { + id, + linkpath, + targetpath, + linkpath_bytes: Some(target_bytes), + targetpath_bytes: Some(link_bytes), } .into(), ) diff --git a/src-tauri/vendor/russh-sftp/src/client/session.rs b/src-tauri/vendor/russh-sftp/src/client/session.rs index a6ab1666..77487520 100644 --- a/src-tauri/vendor/russh-sftp/src/client/session.rs +++ b/src-tauri/vendor/russh-sftp/src/client/session.rs @@ -311,6 +311,15 @@ impl SftpSession { } } + /// Reads a symbolic link using raw bytes for the link path and target. + pub async fn read_link_bytes(&self, path_bytes: Vec) -> SftpResult> { + let name = self.session.readlink_bytes(path_bytes).await?; + match name.files.first() { + Some(file) => Ok(file.filename_bytes.clone()), + None => Err(Error::UnexpectedBehavior("no file".to_owned())), + } + } + /// Removes the specified folder. pub async fn remove_dir>(&self, path: P) -> SftpResult<()> { self.session.rmdir(path).await.map(|_| ()) @@ -373,6 +382,18 @@ impl SftpSession { self.session.symlink_openssh(target, link).await.map(|_| ()) } + /// Creates an OpenSSH-compatible symlink using raw bytes for both paths. + pub async fn symlink_openssh_bytes( + &self, + target_bytes: Vec, + link_bytes: Vec, + ) -> SftpResult<()> { + self.session + .symlink_openssh_bytes(target_bytes, link_bytes) + .await + .map(|_| ()) + } + /// Queries metadata about the remote file. pub async fn metadata>(&self, path: P) -> SftpResult { Ok(self.session.stat(path).await?.attrs) diff --git a/src-tauri/vendor/russh-sftp/src/protocol/readlink.rs b/src-tauri/vendor/russh-sftp/src/protocol/readlink.rs index 4dfdda68..a5324d40 100644 --- a/src-tauri/vendor/russh-sftp/src/protocol/readlink.rs +++ b/src-tauri/vendor/russh-sftp/src/protocol/readlink.rs @@ -1,11 +1,88 @@ +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use super::{impl_packet_for, impl_request_id, Packet, RequestId}; /// Implementation for `SSH_FXP_READLINK` -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug)] pub struct ReadLink { pub id: u32, pub path: String, + /// Raw bytes of the path, preserving its original encoding. + pub path_bytes: Option>, +} + +impl Serialize for ReadLink { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + use serde::ser::SerializeStruct; + let mut state = serializer.serialize_struct("ReadLink", 2)?; + state.serialize_field("id", &self.id)?; + match &self.path_bytes { + Some(bytes) => state.serialize_field("path", bytes)?, + None => state.serialize_field("path", &self.path)?, + } + state.end() + } +} + +impl<'de> Deserialize<'de> for ReadLink { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de::{self, SeqAccess, Visitor}; + use std::fmt; + + struct ReadLinkVisitor; + + impl<'de> Visitor<'de> for ReadLinkVisitor { + type Value = ReadLink; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("struct ReadLink") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let id = seq + .next_element()? + .ok_or_else(|| de::Error::invalid_length(0, &self))?; + let path_bytes: Vec = seq + .next_element()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?; + Ok(ReadLink { + id, + path: String::from_utf8_lossy(&path_bytes).into_owned(), + path_bytes: Some(path_bytes), + }) + } + } + + deserializer.deserialize_struct("ReadLink", &["id", "path"], ReadLinkVisitor) + } } impl_request_id!(ReadLink); impl_packet_for!(ReadLink); + +#[cfg(test)] +mod tests { + use super::ReadLink; + + #[test] + fn serializes_raw_path_bytes_without_lossy_conversion() { + let raw_path = b"/remote/\x80link".to_vec(); + let bytes = crate::ser::to_bytes(&ReadLink { + id: 7, + path: "/remote/display-link".to_string(), + path_bytes: Some(raw_path.clone()), + }) + .unwrap(); + assert!(bytes.ends_with(&raw_path)); + assert!(!bytes.ends_with(b"/remote/display-link")); + } +} diff --git a/src-tauri/vendor/russh-sftp/src/protocol/symlink.rs b/src-tauri/vendor/russh-sftp/src/protocol/symlink.rs index 89546702..0967bd2e 100644 --- a/src-tauri/vendor/russh-sftp/src/protocol/symlink.rs +++ b/src-tauri/vendor/russh-sftp/src/protocol/symlink.rs @@ -1,12 +1,114 @@ +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use super::{impl_packet_for, impl_request_id, Packet, RequestId}; /// Implementation for `SSH_FXP_SYMLINK` -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug)] pub struct Symlink { pub id: u32, pub linkpath: String, pub targetpath: String, + /// Raw bytes of `linkpath`, preserving its original encoding. + pub linkpath_bytes: Option>, + /// Raw bytes of `targetpath`, preserving its original encoding. + pub targetpath_bytes: Option>, +} + +impl Serialize for Symlink { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + use serde::ser::SerializeStruct; + let mut state = serializer.serialize_struct("Symlink", 3)?; + state.serialize_field("id", &self.id)?; + match &self.linkpath_bytes { + Some(bytes) => state.serialize_field("linkpath", bytes)?, + None => state.serialize_field("linkpath", &self.linkpath)?, + } + match &self.targetpath_bytes { + Some(bytes) => state.serialize_field("targetpath", bytes)?, + None => state.serialize_field("targetpath", &self.targetpath)?, + } + state.end() + } +} + +impl<'de> Deserialize<'de> for Symlink { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de::{self, SeqAccess, Visitor}; + use std::fmt; + + struct SymlinkVisitor; + + impl<'de> Visitor<'de> for SymlinkVisitor { + type Value = Symlink; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("struct Symlink") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let id = seq + .next_element()? + .ok_or_else(|| de::Error::invalid_length(0, &self))?; + let linkpath_bytes: Vec = seq + .next_element()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?; + let targetpath_bytes: Vec = seq + .next_element()? + .ok_or_else(|| de::Error::invalid_length(2, &self))?; + Ok(Symlink { + id, + linkpath: String::from_utf8_lossy(&linkpath_bytes).into_owned(), + targetpath: String::from_utf8_lossy(&targetpath_bytes).into_owned(), + linkpath_bytes: Some(linkpath_bytes), + targetpath_bytes: Some(targetpath_bytes), + }) + } + } + + deserializer.deserialize_struct( + "Symlink", + &["id", "linkpath", "targetpath"], + SymlinkVisitor, + ) + } } impl_request_id!(Symlink); impl_packet_for!(Symlink); + +#[cfg(test)] +mod tests { + use super::Symlink; + + #[test] + fn serializes_raw_target_and_link_bytes() { + let raw_target = b"../release/\x81".to_vec(); + let raw_link = b"/remote/\x80current".to_vec(); + let bytes = crate::ser::to_bytes(&Symlink { + id: 9, + linkpath: "display-target".to_string(), + targetpath: "display-link".to_string(), + linkpath_bytes: Some(raw_target.clone()), + targetpath_bytes: Some(raw_link.clone()), + }) + .unwrap(); + let target_pos = bytes + .windows(raw_target.len()) + .position(|window| window == raw_target) + .unwrap(); + let link_pos = bytes + .windows(raw_link.len()) + .position(|window| window == raw_link) + .unwrap(); + assert!(target_pos < link_pos); + } +} diff --git a/src/App.tsx b/src/App.tsx index 7dc747d8..c2c059fa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,8 +6,8 @@ import { toast } from "sonner"; import AppLayout from "./components/app/AppLayout"; import AppPanelContent from "./components/app/AppPanelContent"; import ActivityBarResetDialog from "./components/dialog/app/ActivityBarResetDialog"; -import { McpApprovalHost } from "./components/dialog/app/McpApprovalHost"; import AppOverlayDialogs from "./components/dialog/app/AppOverlayDialogs"; +import { McpApprovalHost } from "./components/dialog/app/McpApprovalHost"; import type { HostKeyVerifyRequest } from "./components/dialog/connections/HostKeyVerifyDialog"; import type { OtpRequest } from "./components/dialog/connections/OtpDialog"; import type { RdpCertificateVerifyRequest } from "./components/dialog/connections/RdpCertificateVerifyDialog"; @@ -23,6 +23,7 @@ import { useFileDocumentCloseGuard } from "./hooks/useFileDocumentCloseGuard"; import { useGlobalShortcuts } from "./hooks/useGlobalShortcuts"; import { useIdleLock } from "./hooks/useIdleLock"; import { useMacSelectionGuard } from "./hooks/useMacSelectionGuard"; +import { useMcpActiveSession } from "./hooks/useMcpActiveSession"; import { useModalChildWindows } from "./hooks/useModalChildWindows"; import { useRemoteGpuOverview } from "./hooks/useRemoteGpuOverview"; import { useRemoteNpuOverview } from "./hooks/useRemoteNpuOverview"; @@ -30,6 +31,7 @@ import { useRemoteStats } from "./hooks/useRemoteStats"; import { useSecurityPromptQueue } from "./hooks/useSecurityPromptQueue"; import { useSessionRuntimeState } from "./hooks/useSessionRuntimeState"; import { resolveDisplayKeys } from "./hooks/useShortcutMap"; +import { useFileEditorZoom } from "./hooks/useFileEditorZoom"; import { useTerminalZoom } from "./hooks/useTerminalZoom"; import { useTabStatusIndicators } from "./hooks/useUnreadTabs"; import { AI_OPEN_EVENT, type AIOpenIntent } from "./lib/aiEvents"; @@ -57,15 +59,15 @@ import { } from "./lib/appSessionFactory"; import { buildPanelOpenUpdate, - canUseFloatingPanel, canCreateSessionFromPane, + canUseFloatingPanel, clearUnavailableFloatingPanels, collectActiveNonSerialSessionIds, EXCLUSIVE_PANEL_IDS, type FloatingPanelsState, + getItemSide, getSideOpenPanels, getSideOverlayPanel, - getItemSide, getVisibleActivityIds, hasLiveSession, isActivityItemAvailable, @@ -155,6 +157,8 @@ import type { AppSettings, AssetMetadata, CloudConflictPreview, + McpSessionOpenCancel, + McpSessionOpenRequest, PaneSplitDirection, RecordingMode, SavedConnection, @@ -968,6 +972,9 @@ function App() { options?: { failureContext?: string; runtimeModeOverride?: SshRuntimeMode; + propagateError?: boolean; + onPending?: (pending: { tabId: string; createRequestId: string }) => void; + onSuccess?: (sessionId: string) => void; }, ) => { const pending = addPendingTab( @@ -979,6 +986,7 @@ function App() { { display: getRemoteDesktopPaneDisplay(connection) }, ); const { tabId, createRequestId } = pending; + options?.onPending?.({ tabId, createRequestId }); try { const sessionId = await createSessionForConnection( @@ -995,8 +1003,10 @@ function App() { focusTerminalSession(sessionId); recordRecentConnection(connection.id); updateAutoIconForSessionStart(connection.id, sessionId); + options?.onSuccess?.(sessionId); } catch (error) { if (isSessionCreationCancelled(error) || !hasTab(tabId)) { + if (options?.propagateError) throw error; return; } const errorMessage = getErrorMessage(error); @@ -1012,6 +1022,7 @@ function App() { sourceTabId: tabId, }); toast.error(t("savedConnections.connectionFailed", { error: errorMessage })); + if (options?.propagateError) throw error; } }, [ @@ -1026,6 +1037,89 @@ function App() { ], ); + const mcpSessionOpenRequestsRef = useRef( + new Map(), + ); + const cancelledMcpSessionOpenRequestsRef = useRef(new Set()); + useEffect(() => { + let disposed = false; + let unlistenOpen: (() => void) | undefined; + let unlistenCancel: (() => void) | undefined; + + void listen("mcp-session-open-request", ({ payload }) => { + if (disposed || !eventTargetsCurrentWindow(payload.targetWindowLabel)) return; + void (async () => { + const connections = savedConnections.some((item) => item.id === payload.connectionId) + ? savedConnections + : await invoke("get_saved_connections"); + if (cancelledMcpSessionOpenRequestsRef.current.delete(payload.requestId)) return; + const connection = connections.find((item) => item.id === payload.connectionId); + if (!connection || connection.type === "rdp" || connection.type === "vnc") { + await invoke("respond_mcp_session_open", { + requestId: payload.requestId, + sessionId: null, + error: "The saved connection does not exist or is not a supported terminal connection.", + }); + return; + } + + let openedSessionId: string | null = null; + await connectSavedConnection(connection, { + failureContext: "MCP session open failed", + propagateError: true, + onPending: (pending) => { + mcpSessionOpenRequestsRef.current.set(payload.requestId, pending); + }, + onSuccess: (sessionId) => { + openedSessionId = sessionId; + }, + }); + await invoke("respond_mcp_session_open", { + requestId: payload.requestId, + sessionId: openedSessionId, + error: openedSessionId ? null : "The MCP session-open request did not create a session.", + }); + })() + .catch((error) => { + void invoke("respond_mcp_session_open", { + requestId: payload.requestId, + sessionId: null, + error: getErrorMessage(error), + }).catch(() => {}); + }) + .finally(() => { + mcpSessionOpenRequestsRef.current.delete(payload.requestId); + cancelledMcpSessionOpenRequestsRef.current.delete(payload.requestId); + }); + }).then((dispose) => { + if (disposed) dispose(); + else unlistenOpen = dispose; + }); + + void listen("mcp-session-open-cancel", ({ payload }) => { + if (disposed || !eventTargetsCurrentWindow(payload.targetWindowLabel)) return; + const pending = mcpSessionOpenRequestsRef.current.get(payload.requestId); + if (!pending) { + cancelledMcpSessionOpenRequestsRef.current.add(payload.requestId); + return; + } + mcpSessionOpenRequestsRef.current.delete(payload.requestId); + closeTabs([pending.tabId]); + void invoke("cancel_session_creation", { + createRequestId: pending.createRequestId, + }).catch(() => {}); + }).then((dispose) => { + if (disposed) dispose(); + else unlistenCancel = dispose; + }); + + return () => { + disposed = true; + unlistenOpen?.(); + unlistenCancel?.(); + }; + }, [closeTabs, connectSavedConnection, savedConnections]); + const connectTemporaryConnection = useCallback( async (config: TemporaryLinkConfig) => { const pending = addPendingTab( @@ -1066,18 +1160,11 @@ function App() { const connectExternalLocalSession = useCallback( async (workingDir: string | null) => { - const pending = addPendingTab( - t("menu.newLocalTerminal"), - "Local", - undefined, - ); + const pending = addPendingTab(t("menu.newLocalTerminal"), "Local", undefined); const { tabId, createRequestId } = pending; try { - const sessionId = await createExternalLocalSession( - workingDir, - createRequestId, - ); + const sessionId = await createExternalLocalSession(workingDir, createRequestId); if (!hasTab(tabId)) { await closeStaleCreatedSession(sessionId); return; @@ -1963,6 +2050,8 @@ function App() { appSettings.interaction.terminal_zoom_enabled, ); + useFileEditorZoom(updateAppSettings); + const handleOpenSettings = useCallback(() => { openSettings(); }, []); @@ -3088,6 +3177,7 @@ function App() { !activePane.connectError ? activePane.sessionId : null; + useMcpActiveSession(activeSessionId); const activeSshSessionId = activePane && activePane.paneKind === "terminal" && diff --git a/src/components/dialog/app/McpApprovalHost.tsx b/src/components/dialog/app/McpApprovalHost.tsx index ba88128f..e7149249 100644 --- a/src/components/dialog/app/McpApprovalHost.tsx +++ b/src/components/dialog/app/McpApprovalHost.tsx @@ -92,9 +92,13 @@ export function McpApprovalHost() {
- {t("ai.externalMcpSession")}: + {t("ai.externalMcpTarget")}: {" "} - {current.sessionName ?? current.sessionId ?? "-"} + {current.connectionName ?? + current.connectionId ?? + current.sessionName ?? + current.sessionId ?? + "-"}
diff --git a/src/components/dialog/connections/ImportDialog.tsx b/src/components/dialog/connections/ImportDialog.tsx index f4f7c5a4..86fcd93c 100644 --- a/src/components/dialog/connections/ImportDialog.tsx +++ b/src/components/dialog/connections/ImportDialog.tsx @@ -31,8 +31,9 @@ interface ImportSource { icon: string | ComponentType<{ className?: string }>; extensions?: string[]; hint?: string; - type: "backup" | "sessions"; + type: "backup" | "sessions" | "ssh_config"; picker?: "file" | "directory"; + labelKey?: string; } const IMPORT_SOURCES: ImportSource[] = [ @@ -108,6 +109,14 @@ const IMPORT_SOURCES: ImportSource[] = [ hint: ".json", type: "sessions", }, + { + id: "ssh_config", + name: "SSH Config", + icon: MdTerminal, + hint: "~/.ssh/config", + type: "ssh_config", + labelKey: "savedConnections.sshConfigSource", + }, ]; const SESSION_IMPORT_DOC_URLS = { @@ -170,6 +179,27 @@ export default function ImportDialog({ open, onClose }: ImportDialogProps) { return; } + if (source.id === "ssh_config") { + try { + const count = await invoke("import_ssh_config_hosts"); + if (count > 0) { + toast.success(t("savedConnections.importSuccess", { count })); + } else { + toast.info(t("savedConnections.importSuccess", { count: 0 })); + } + refreshConnections(); + } catch (e) { + logger.error({ + domain: "settings.persistence", + event: "sessions.import_ssh_config_failed", + message: "Import SSH config failed", + error: e, + }); + toast.error(t("savedConnections.importFailed", { error: e })); + } + return; + } + if (source.id === "termius") { try { const count = await invoke("import_termius_sessions", { indexedDbPath: null }); @@ -268,7 +298,7 @@ export default function ImportDialog({ open, onClose }: ImportDialogProps) { > {renderSourceIcon(source)} - {source.name} + {source.labelKey ? t(source.labelKey) : source.name} {source.hint && ( ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock("@/lib/invoke", () => ({ invoke: vi.fn() })); +vi.mock("sonner", () => ({ + toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() }, +})); + +const symlinkProperties: FileProperties = { + name: "current", + is_dir: true, + is_symlink: true, + symlink_target: "releases/v2", + size: 11, + permissions: "lrwxrwxrwx", + owner: "root", + group: "root", + uid: "0", + gid: "0", + mtime: 0, + atime: 0, +}; + +const remoteData: PropertiesDialogData = { + sessionId: "session-1", + backend: "remote", + fullPath: "/opt/app/current", + rawPathToken: "raw-current", + name: "current", + is_dir: true, +}; + +describe("PropertiesDialog symlink target", () => { + beforeEach(() => { + vi.mocked(invoke).mockReset(); + }); + + it("shows the original target and identifies links before directories", async () => { + vi.mocked(invoke).mockResolvedValueOnce(symlinkProperties); + + render(); + + const targetInput = await screen.findByRole("textbox", { + name: "fileExplorer.symlinkTarget", + }); + expect((targetInput as HTMLInputElement).value).toBe("releases/v2"); + expect(screen.getByText("fileExplorer.symbolicLink")).not.toBeNull(); + expect(screen.queryByText("fileExplorer.folder")).toBeNull(); + }); + + it("preserves target whitespace and saves it before attribute changes", async () => { + vi.mocked(invoke) + .mockResolvedValueOnce(symlinkProperties) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined); + const onClose = vi.fn(); + const onSuccess = vi.fn(); + + render( + , + ); + + fireEvent.change( + await screen.findByRole("textbox", { + name: "fileExplorer.symlinkTarget", + }), + { target: { value: " ../releases/v3 " } }, + ); + fireEvent.change(screen.getAllByDisplayValue("root")[0], { + target: { value: "deploy" }, + }); + fireEvent.click(screen.getByRole("button", { name: "dialog.save" })); + + await waitFor(() => expect(invoke).toHaveBeenCalledTimes(3)); + expect(invoke).toHaveBeenNthCalledWith(2, "update_remote_symlink_target", { + sessionId: "session-1", + path: "/opt/app/current", + rawPathToken: "raw-current", + targetPath: " ../releases/v3 ", + }); + expect(invoke).toHaveBeenNthCalledWith(3, "update_remote_file_attributes", { + sessionId: "session-1", + path: "/opt/app/current", + rawPathToken: "raw-current", + update: { + mode: null, + owner: "deploy", + group: null, + recursive: false, + }, + }); + expect(onSuccess).toHaveBeenCalledOnce(); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("rejects blank targets without sending an update", async () => { + vi.mocked(invoke).mockResolvedValueOnce(symlinkProperties); + const { toast } = await import("sonner"); + + render(); + + fireEvent.change( + await screen.findByRole("textbox", { + name: "fileExplorer.symlinkTarget", + }), + { target: { value: " " } }, + ); + fireEvent.click(screen.getByRole("button", { name: "dialog.save" })); + + expect(toast.error).toHaveBeenCalledWith( + "fileExplorer.symlinkTargetRequired", + ); + expect(invoke).toHaveBeenCalledTimes(1); + }); + + it("closes without an update when nothing changed", async () => { + vi.mocked(invoke).mockResolvedValueOnce(symlinkProperties); + const onClose = vi.fn(); + + render(); + + await screen.findByRole("textbox", { name: "fileExplorer.symlinkTarget" }); + fireEvent.click(screen.getByRole("button", { name: "dialog.save" })); + + expect(invoke).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("does not expose target editing for local links or regular remote files", async () => { + vi.mocked(invoke).mockResolvedValueOnce(symlinkProperties); + const { unmount } = render( + , + ); + + await screen.findByText("fileExplorer.symbolicLink"); + expect( + screen.queryByRole("textbox", { name: "fileExplorer.symlinkTarget" }), + ).toBeNull(); + unmount(); + + vi.mocked(invoke).mockResolvedValueOnce({ + ...symlinkProperties, + is_dir: false, + is_symlink: false, + symlink_target: null, + }); + render( + , + ); + + await screen.findByText("fileExplorer.file"); + expect( + screen.queryByRole("textbox", { name: "fileExplorer.symlinkTarget" }), + ).toBeNull(); + }); +}); diff --git a/src/components/dialog/file-explorer/PropertiesDialog.tsx b/src/components/dialog/file-explorer/PropertiesDialog.tsx index 8009ad9b..4d91f373 100644 --- a/src/components/dialog/file-explorer/PropertiesDialog.tsx +++ b/src/components/dialog/file-explorer/PropertiesDialog.tsx @@ -90,7 +90,11 @@ function parsePermissionsToOctal(perms: string): string { return `${special}${u}${g}${o}`; } -export default function PropertiesDialog({ data, onClose, onSuccess }: PropertiesDialogProps) { +export default function PropertiesDialog({ + data, + onClose, + onSuccess, +}: PropertiesDialogProps) { const { t } = useTranslation(); const [properties, setProperties] = useState(null); const [loading, setLoading] = useState(true); @@ -99,12 +103,18 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie const [octal, setOctal] = useState("0644"); const [ownerInput, setOwnerInput] = useState(""); const [groupInput, setGroupInput] = useState(""); + const [symlinkTarget, setSymlinkTarget] = useState(""); const [recursive, setRecursive] = useState(false); const [isSaving, setIsSaving] = useState(false); - const initialOctal = properties ? parsePermissionsToOctal(properties.permissions) : "0644"; + const initialOctal = properties + ? parsePermissionsToOctal(properties.permissions) + : "0644"; const initialOwner = properties?.owner || properties?.uid || ""; const initialGroup = properties?.group || properties?.gid || ""; + const initialSymlinkTarget = properties?.symlink_target ?? ""; const canEditAttributes = data.backend === "remote"; + const canEditSymlinkTarget = + data.backend === "remote" && properties?.is_symlink === true; useEffect(() => { let isMounted = true; @@ -128,6 +138,7 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie setOctal(parsePermissionsToOctal(props.permissions)); setOwnerInput(props.owner || props.uid || ""); setGroupInput(props.group || props.gid || ""); + setSymlinkTarget(props.symlink_target ?? ""); setRecursive(false); } }) @@ -147,6 +158,10 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie const nextOwner = ownerInput.trim(); const nextGroup = groupInput.trim(); + if (canEditSymlinkTarget && !symlinkTarget.trim()) { + toast.error(t("fileExplorer.symlinkTargetRequired")); + return; + } if (!nextOwner || !nextGroup) { toast.error(t("fileExplorer.ownerGroupRequired")); return; @@ -158,21 +173,40 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie group: nextGroup !== initialGroup ? nextGroup : null, recursive: data.is_dir && recursive, }; + const symlinkTargetChanged = + canEditSymlinkTarget && symlinkTarget !== initialSymlinkTarget; + const attributesChanged = !!(update.mode || update.owner || update.group); - if (!update.mode && !update.owner && !update.group) { + if (!attributesChanged && !symlinkTargetChanged) { onClose(); return; } setIsSaving(true); try { - await invoke("update_remote_file_attributes", { - sessionId: data.sessionId, - path: data.fullPath, - rawPathToken: data.rawPathToken, - update, - }); - toast.success(t("fileExplorer.propertiesSaved")); + if (symlinkTargetChanged) { + await invoke("update_remote_symlink_target", { + sessionId: data.sessionId, + path: data.fullPath, + rawPathToken: data.rawPathToken, + targetPath: symlinkTarget, + }); + } + if (attributesChanged) { + await invoke("update_remote_file_attributes", { + sessionId: data.sessionId, + path: data.fullPath, + rawPathToken: data.rawPathToken, + update, + }); + } + toast.success( + t( + symlinkTargetChanged + ? "fileExplorer.symlinkTargetSaved" + : "fileExplorer.propertiesSaved", + ), + ); await onSuccess?.(); onClose(); } catch (e) { @@ -201,6 +235,7 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie }; const getFileType = () => { + if (properties?.is_symlink) return t("fileExplorer.symbolicLink"); if (data.is_dir) return t("fileExplorer.folder"); const ext = data.name.split(".").pop()?.toLowerCase(); if (ext === "sh" || ext === "bash") return t("fileExplorer.shellScript"); @@ -212,19 +247,29 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie }; return ( - !v && !isSaving && onClose()}> + !v && !isSaving && onClose()} + > {data.is_dir ? ( - + ) : ( )} - + {t("fileExplorer.propertiesOf", { name: data.name })} @@ -253,16 +298,42 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie
{[ - { key: "type", label: t("fileExplorer.type"), value: getFileType() }, + { + key: "type", + label: t("fileExplorer.type"), + value: getFileType(), + }, { key: "location", label: t("fileExplorer.location"), value: ( - + {getLocation()} ), }, + ...(canEditSymlinkTarget + ? [ + { + key: "symlinkTarget", + label: t("fileExplorer.symlinkTarget"), + value: ( + + setSymlinkTarget(event.target.value) + } + /> + ), + }, + ] + : []), { key: "size", label: t("fileExplorer.size"), @@ -271,12 +342,20 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie { key: "mtime", label: t("fileExplorer.mtime"), - value: {formatTime(properties.mtime)}, + value: ( + + {formatTime(properties.mtime)} + + ), }, { key: "atime", label: t("fileExplorer.atime"), - value: {formatTime(properties.atime)}, + value: ( + + {formatTime(properties.atime)} + + ), }, { key: "owner", @@ -285,7 +364,9 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie {properties.owner || "-"}{" "} {properties.uid && ( - [{properties.uid}] + + [{properties.uid}] + )} ), @@ -297,15 +378,21 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie {properties.group || "-"}{" "} {properties.gid && ( - [{properties.gid}] + + [{properties.gid}] + )} ), }, ].map((row) => (
- {row.label}: - {row.value} + + {row.label}: + + + {row.value} +
))}
@@ -321,7 +408,9 @@ export default function PropertiesDialog({ data, onClose, onSuccess }: Propertie